feat(mui): consistent staggered page entrance across all 43 route pages

Adopt the PageEnter wrapper as every page's outermost render element and remove the ad-hoc per-page entrance motion.div wrappers. Every page now enters the same way — all top-level sections rise+fade in, staggered — so nothing appears instantly and the motion is identical app-wide. Presentation-only: no data/logic/hooks/invalidate/permissions touched. Embedded sub-tabs (CompanySettings, ReceivedInvoices), Login (auth shell) and the dev UiKit are intentionally excluded. Gates: tsc -b --noEmit=0, build=0, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 10:13:06 +02:00
parent b273614128
commit 39fe84ce99
43 changed files with 4956 additions and 5709 deletions

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import { parseISO, isValid } from "date-fns"; import { parseISO, isValid } from "date-fns";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -30,6 +29,7 @@ import {
StatusChip, StatusChip,
TextField, TextField,
ProgressBar, ProgressBar,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
@@ -614,24 +614,18 @@ export default function Attendance() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="h4">Docházka</Typography>
animate={{ opacity: 1, y: 0 }} <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
transition={{ duration: 0.25 }} {new Date().toLocaleDateString("cs-CZ", {
> weekday: "long",
<Box sx={{ mb: 3 }}> day: "numeric",
<Typography variant="h4">Docházka</Typography> month: "long",
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> year: "numeric",
{new Date().toLocaleDateString("cs-CZ", { })}
weekday: "long", </Typography>
day: "numeric", </Box>
month: "long",
year: "numeric",
})}
</Typography>
</Box>
</motion.div>
<Box <Box
sx={{ sx={{
@@ -642,11 +636,7 @@ export default function Attendance() {
}} }}
> >
{/* Left Column - Clock In/Out */} {/* Left Column - Clock In/Out */}
<motion.div <>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card> <Card>
<Box <Box
sx={{ sx={{
@@ -986,35 +976,63 @@ export default function Attendance() {
/> />
</Card> </Card>
)} )}
</motion.div> </>
{/* Right Column - Stats & Quick Links */} {/* Right Column - Stats & Quick Links */}
<motion.div <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
initial={{ opacity: 0, y: 12 }} {/* Leave Balance Card */}
animate={{ opacity: 1, y: 0 }} <StatCard
transition={{ duration: 0.25, delay: 0.06 }} icon={VacationIcon}
> color="success"
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}> label={`Dovolená ${new Date().getFullYear()}`}
{/* Leave Balance Card */} value={
<>
{vacationDaysRemaining}{" "}
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ fontFamily: "inherit" }}
>
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</Typography>
</>
}
footer={
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 0.75,
}}
>
<span>Celkem: {leaveBalance.vacation_total}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
</Box>
<ProgressBar
value={
leaveBalance.vacation_total > 0
? (leaveBalance.vacation_remaining /
leaveBalance.vacation_total) *
100
: 0
}
color="success"
height={6}
/>
</Box>
}
/>
{/* Monthly Fund Card */}
{data.monthly_fund && (
<StatCard <StatCard
icon={VacationIcon} icon={CalendarIcon}
color="success" color="info"
label={`Dovolená ${new Date().getFullYear()}`} label={data.monthly_fund.month_name}
value={ value={`${data.monthly_fund.worked}h / ${data.monthly_fund.fund}h`}
<>
{vacationDaysRemaining}{" "}
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ fontFamily: "inherit" }}
>
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 &&
` ${vacationHoursRemaining}h`}
</Typography>
</>
}
footer={ footer={
<Box> <Box>
<Box <Box
@@ -1024,137 +1042,102 @@ export default function Attendance() {
mb: 0.75, mb: 0.75,
}} }}
> >
<span>Celkem: {leaveBalance.vacation_total}h</span> <span>Odpracováno: {data.monthly_fund.worked}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span> {data.monthly_fund.overtime > 0 ? (
<Box
component="span"
sx={{ color: "warning.main", fontWeight: 600 }}
>
Přesčas: +{data.monthly_fund.overtime}h
</Box>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</Box> </Box>
<ProgressBar <ProgressBar
value={ value={
leaveBalance.vacation_total > 0 data.monthly_fund.fund > 0
? (leaveBalance.vacation_remaining / ? Math.min(
leaveBalance.vacation_total) * 100,
100 (data.monthly_fund.covered /
data.monthly_fund.fund) *
100,
)
: 0 : 0
} }
color="success" color={getFundBarColor(data.monthly_fund)}
height={6} height={6}
/> />
{data.monthly_fund.leave_hours > 0 && (
<Box sx={{ mt: 0.5, color: "text.disabled" }}>
{"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "}
{data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 &&
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
</Box>
)}
</Box> </Box>
} }
/> />
)}
{/* Monthly Fund Card */} {/* Sick Leave Card */}
{data.monthly_fund && ( <StatCard
<StatCard icon={SickIcon}
icon={CalendarIcon} color="error"
color="info" label={`Nemoc ${new Date().getFullYear()}`}
label={data.monthly_fund.month_name} value={`${leaveBalance.sick_used}h čerpáno`}
value={`${data.monthly_fund.worked}h / ${data.monthly_fund.fund}h`} />
footer={
<Box> {/* Quick Links */}
<Box <Card>
sx={{ <Typography
display: "flex", variant="caption"
justifyContent: "space-between", color="text.secondary"
mb: 0.75, sx={{
}} display: "block",
> textTransform: "uppercase",
<span>Odpracováno: {data.monthly_fund.worked}h</span> letterSpacing: ".06em",
{data.monthly_fund.overtime > 0 ? ( fontWeight: 700,
<Box mb: 1,
component="span" }}
sx={{ color: "warning.main", fontWeight: 600 }} >
> Rychlé odkazy
Přesčas: +{data.monthly_fund.overtime}h </Typography>
</Box> <Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
) : ( <QuickLink
<span>Zbývá: {data.monthly_fund.remaining}h</span> to="/attendance/requests"
)} icon={RequestsIcon}
</Box> label="Moje žádosti"
<ProgressBar
value={
data.monthly_fund.fund > 0
? Math.min(
100,
(data.monthly_fund.covered /
data.monthly_fund.fund) *
100,
)
: 0
}
color={getFundBarColor(data.monthly_fund)}
height={6}
/>
{data.monthly_fund.leave_hours > 0 && (
<Box sx={{ mt: 0.5, color: "text.disabled" }}>
{"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "}
{data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 &&
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
</Box>
)}
</Box>
}
/> />
)} <QuickLink
to="/attendance/history"
{/* Sick Leave Card */} icon={HistoryIcon}
<StatCard label="Historie docházky"
icon={SickIcon} />
color="error" {hasPermission("attendance.manage") && (
label={`Nemoc ${new Date().getFullYear()}`}
value={`${leaveBalance.sick_used}h čerpáno`}
/>
{/* Quick Links */}
<Card>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".06em",
fontWeight: 700,
mb: 1,
}}
>
Rychlé odkazy
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
<QuickLink <QuickLink
to="/attendance/requests" to="/attendance/admin"
icon={RequestsIcon} icon={ManageIcon}
label="Moje žádosti" label="Správa docházky"
/> />
)}
{hasPermission("attendance.balances") && (
<QuickLink <QuickLink
to="/attendance/history" to="/attendance/balances"
icon={HistoryIcon} icon={BalancesIcon}
label="Historie docházky" label="Správa bilancí"
/> />
{hasPermission("attendance.manage") && ( )}
<QuickLink </Box>
to="/attendance/admin" </Card>
icon={ManageIcon} </Box>
label="Správa docházky"
/>
)}
{hasPermission("attendance.balances") && (
<QuickLink
to="/attendance/balances"
icon={BalancesIcon}
label="Správa bilancí"
/>
)}
</Box>
</Card>
</Box>
</motion.div>
</Box> </Box>
{/* Leave Modal */} {/* Leave Modal */}
@@ -1273,6 +1256,6 @@ export default function Attendance() {
cancelText="Zrušit" cancelText="Zrušit"
confirmVariant="primary" confirmVariant="primary"
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -18,6 +17,7 @@ import {
FilterBar, FilterBar,
LoadingState, LoadingState,
MonthField, MonthField,
PageEnter,
Select, Select,
StatCard, StatCard,
StatusChip, StatusChip,
@@ -165,262 +165,236 @@ export default function AttendanceAdmin() {
const hasTotals = Object.keys(data.user_totals).length > 0; const hasTotals = Object.keys(data.user_totals).length > 0;
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Typography variant="h4">Správa docházky</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasData && (
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
title="Tisk docházky"
>
Tisk
</Button>
)}
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
Vyplnit měsíc
</Button>
<Button startIcon={AddIcon} onClick={openCreateModal}>
Přidat záznam
</Button>
</Box>
</Box>
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 200px" }}>
<Field label="Měsíc">
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Field>
</Box>
<Box sx={{ flex: "1 1 240px" }}>
<Field label="Zaměstnanec">
<Select
value={filterUserId}
onChange={setFilterUserId}
options={[
{ value: "", label: "Všichni" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
</Box>
</FilterBar>
{/* User Totals (KPI cards) */}
{hasTotals && (
<Box <Box
sx={{ sx={{
display: "flex", display: "grid",
alignItems: "flex-start", gridTemplateColumns: {
justifyContent: "space-between", xs: "1fr",
flexWrap: "wrap", sm: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
},
gap: 2, gap: 2,
mb: 3, mb: 3,
}} }}
> >
<Typography variant="h4">Správa docházky</Typography> {Object.entries(data.user_totals).map(([uid, userData]) => {
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}> const ut = userData as UserTotalData;
{hasData && ( const balance = data.leave_balances[uid];
<Button const statColor: StatCardColor = ut.working ? "success" : "default";
variant="outlined" return (
color="inherit" <StatCard
startIcon={PrintIcon} key={uid}
onClick={handlePrint} label={ut.name}
title="Tisk docházky" value={
> <Box
Tisk component="span"
</Button> sx={{
)} display: "inline-flex",
<Button variant="outlined" color="inherit" onClick={openBulkModal}> alignItems: "center",
Vyplnit měsíc gap: 1,
</Button> }}
<Button startIcon={AddIcon} onClick={openCreateModal}> >
Přidat záznam {formatMinutes(ut.minutes)}
</Button> <StatusChip
</Box> color={ut.working ? "success" : "default"}
</Box> label={ut.working ? "✓" : "✗"}
</motion.div> />
</Box>
{/* Filters */} }
<motion.div color={statColor}
initial={{ opacity: 0, y: 12 }} footer={
animate={{ opacity: 1, y: 0 }} <Box>
transition={{ duration: 0.25, delay: 0.06 }} {/* Leave-type badges */}
>
<FilterBar>
<Box sx={{ flex: "0 0 200px" }}>
<Field label="Měsíc">
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Field>
</Box>
<Box sx={{ flex: "1 1 240px" }}>
<Field label="Zaměstnanec">
<Select
value={filterUserId}
onChange={setFilterUserId}
options={[
{ value: "", label: "Všichni" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
</Box>
</FilterBar>
</motion.div>
{/* User Totals (KPI cards) */}
{hasTotals && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
},
gap: 2,
mb: 3,
}}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
const balance = data.leave_balances[uid];
const statColor: StatCardColor = ut.working
? "success"
: "default";
return (
<StatCard
key={uid}
label={ut.name}
value={
<Box <Box
component="span"
sx={{ sx={{
display: "inline-flex", display: "flex",
flexWrap: "wrap",
gap: 0.5,
mb: 1,
minHeight: "1.75rem",
alignItems: "center", alignItems: "center",
gap: 1,
}} }}
> >
{formatMinutes(ut.minutes)} {ut.vacation_hours > 0 && (
<StatusChip <StatusChip
color={ut.working ? "success" : "default"} color="info"
label={ut.working ? "✓" : "✗"} label={`Dov: ${ut.vacation_hours}h`}
/> />
)}
{ut.sick_hours > 0 && (
<StatusChip
color="error"
label={`Nem: ${ut.sick_hours}h`}
/>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
<StatusChip
color="warning"
label={`Sv: ${Math.round(ut.svatek)}h`}
/>
)}
{ut.unpaid_hours > 0 && (
<StatusChip
color="default"
label={`Nep: ${ut.unpaid_hours}h`}
/>
)}
</Box> </Box>
}
color={statColor}
footer={
<Box>
{/* Leave-type badges */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 0.5,
mb: 1,
minHeight: "1.75rem",
alignItems: "center",
}}
>
{ut.vacation_hours > 0 && (
<StatusChip
color="info"
label={`Dov: ${ut.vacation_hours}h`}
/>
)}
{ut.sick_hours > 0 && (
<StatusChip
color="error"
label={`Nem: ${ut.sick_hours}h`}
/>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
<StatusChip
color="warning"
label={`Sv: ${Math.round(ut.svatek)}h`}
/>
)}
{ut.unpaid_hours > 0 && (
<StatusChip
color="default"
label={`Nep: ${ut.unpaid_hours}h`}
/>
)}
</Box>
{/* Fond usage */} {/* Fond usage */}
{ut.fund !== null && {ut.fund !== null &&
(() => { (() => {
// Fond "used" = real_worked + vacation + // Fond "used" = real_worked + vacation +
// worked_holiday + free_holiday (everything the user // worked_holiday + free_holiday (everything the user
// actually got paid for this month). // actually got paid for this month).
const fondUsed = getFondUsed(ut); const fondUsed = getFondUsed(ut);
const fundVal = ut.fund ?? 0; const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal; const delta = fondUsed - fundVal;
const pct = Math.min( const pct = Math.min(
100, 100,
(fondUsed / (ut.fund || 1)) * 100, (fondUsed / (ut.fund || 1)) * 100,
); );
return ( return (
<Box> <Box>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
mb: 0.5, mb: 0.5,
}} }}
>
<Typography
variant="caption"
color="text.secondary"
> >
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</Typography>
{delta > 0 && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" sx={{
fontWeight: 600,
color: "warning.main",
}}
> >
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "} +{formatHoursDecimal(delta * 60)}h
{formatHoursDecimal(fundVal * 60)}h
</Typography> </Typography>
{delta > 0 && ( )}
<Typography {delta < 0 && (
variant="caption" <Typography
sx={{ variant="caption"
fontWeight: 600, sx={{
color: "warning.main", fontWeight: 600,
}} color: "error.main",
> }}
+{formatHoursDecimal(delta * 60)}h >
</Typography> -{formatHoursDecimal(Math.abs(delta) * 60)}h
)} </Typography>
{delta < 0 && ( )}
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "error.main",
}}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</Typography>
)}
</Box>
<ProgressBar
value={pct}
color={getFundBarColor(ut)}
height={4}
/>
</Box> </Box>
); <ProgressBar
})()} value={pct}
color={getFundBarColor(ut)}
height={4}
/>
</Box>
);
})()}
{/* Remaining vacation */} {/* Remaining vacation */}
<Box sx={{ mt: 1.5 }}> <Box sx={{ mt: 1.5 }}>
{balance ? ( {balance ? (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Zbývá dovolené:{" "} Zbývá dovolené:{" "}
{balance.vacation_remaining.toFixed(1)}h /{" "} {balance.vacation_remaining.toFixed(1)}h /{" "}
{balance.vacation_total}h {balance.vacation_total}h
</Typography> </Typography>
) : ( ) : (
<Typography <Typography
variant="caption" variant="caption"
sx={{ visibility: "hidden" }} sx={{ visibility: "hidden" }}
> >
</Typography> </Typography>
)} )}
</Box>
</Box> </Box>
} </Box>
/> }
); />
})} );
</Box> })}
</motion.div> </Box>
)} )}
{/* Records Table */} {/* Records Table */}
<motion.div <Card>
initial={{ opacity: 0, y: 12 }} <AttendanceShiftTable
animate={{ opacity: 1, y: 0 }} records={data.records}
transition={{ duration: 0.25, delay: 0.12 }} onEdit={openEditModal}
> onDelete={(record) => setDeleteConfirm({ show: true, record })}
<Card> />
<AttendanceShiftTable </Card>
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</Card>
</motion.div>
{/* Modals */} {/* Modals */}
<BulkAttendanceModal <BulkAttendanceModal
@@ -481,6 +455,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -25,6 +25,7 @@ import {
Select, Select,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
ProgressBar, ProgressBar,
@@ -392,7 +393,7 @@ export default function AttendanceBalances() {
]; ];
return ( return (
<Box> <PageEnter>
<PageHeader <PageHeader
title="Správa bilancí" title="Správa bilancí"
actions={ actions={
@@ -815,6 +816,6 @@ export default function AttendanceBalances() {
confirmVariant="danger" confirmVariant="danger"
loading={resetMutation.isPending} loading={resetMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { motion } from "framer-motion";
import { userListOptions } from "../lib/queries/users"; import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -17,6 +16,7 @@ import {
DateField, DateField,
Field, Field,
LoadingState, LoadingState,
PageEnter,
PageHeader, PageHeader,
Select, Select,
TextField, TextField,
@@ -118,230 +118,214 @@ export default function AttendanceCreate() {
} }
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Přidat záznam docházky"
animate={{ opacity: 1, y: 0 }} actions={
transition={{ duration: 0.25 }} <Button
> variant="outlined"
<PageHeader color="inherit"
title="Přidat záznam docházky" component={RouterLink}
actions={ to="/attendance/admin"
>
Zpět na správu
</Button>
}
/>
<Card sx={{ maxWidth: 640 }}>
<Box component="form" onSubmit={handleSubmit}>
{/* Employee + Shift date */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(val) => setForm({ ...form, user_id: val })}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...users.map((u) => ({
value: String(u.id),
label: u.name,
})),
]}
/>
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date}
onChange={(val) => handleShiftDateChange(val)}
/>
</Field>
</Box>
{/* Record type */}
<Field label="Typ záznamu" required>
<Select
value={form.leave_type}
onChange={(val) => setForm({ ...form, leave_type: val })}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{/* Leave hours — shown only for non-work types */}
{!isWorkType && (
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
<TextField
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
</Field>
)}
{/* Work-type time fields */}
{isWorkType && (
<>
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val) => setForm({ ...form, arrival_date: val })}
/>
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</Field>
</Box>
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val) =>
setForm({ ...form, break_start_date: val })
}
/>
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/>
</Field>
</Box>
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val) =>
setForm({ ...form, break_end_date: val })
}
/>
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
/>
</Field>
</Box>
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val) =>
setForm({ ...form, departure_date: val })
}
/>
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/>
</Field>
</Box>
</>
)}
{/* Notes */}
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</Field>
{/* Actions */}
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
component={RouterLink} component={RouterLink}
to="/attendance/admin" to="/attendance/admin"
> >
Zpět na správu Zrušit
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Ukládám..." : "Uložit"}
</Button> </Button>
}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ maxWidth: 640 }}>
<Box component="form" onSubmit={handleSubmit}>
{/* Employee + Shift date */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(val) => setForm({ ...form, user_id: val })}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...users.map((u) => ({
value: String(u.id),
label: u.name,
})),
]}
/>
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date}
onChange={(val) => handleShiftDateChange(val)}
/>
</Field>
</Box>
{/* Record type */}
<Field label="Typ záznamu" required>
<Select
value={form.leave_type}
onChange={(val) => setForm({ ...form, leave_type: val })}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{/* Leave hours — shown only for non-work types */}
{!isWorkType && (
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
<TextField
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
</Field>
)}
{/* Work-type time fields */}
{isWorkType && (
<>
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val) =>
setForm({ ...form, arrival_date: val })
}
/>
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
/>
</Field>
</Box>
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val) =>
setForm({ ...form, break_start_date: val })
}
/>
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/>
</Field>
</Box>
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val) =>
setForm({ ...form, break_end_date: val })
}
/>
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
/>
</Field>
</Box>
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val) =>
setForm({ ...form, departure_date: val })
}
/>
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/>
</Field>
</Box>
</>
)}
{/* Notes */}
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</Field>
{/* Actions */}
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
<Button
variant="outlined"
color="inherit"
component={RouterLink}
to="/attendance/admin"
>
Zrušit
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Ukládám..." : "Uložit"}
</Button>
</Box>
</Box> </Box>
</Card> </Box>
</motion.div> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState, useMemo, useRef } from "react"; import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -36,6 +35,7 @@ import {
ProgressBar, ProgressBar,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
@@ -494,196 +494,169 @@ export default function AttendanceHistory() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Historie docházky"
animate={{ opacity: 1, y: 0 }} subtitle={computed.monthName}
transition={{ duration: 0.25 }} actions={
> records.length > 0 ? (
<PageHeader <Button
title="Historie docházky" variant="outlined"
subtitle={computed.monthName} color="inherit"
actions={ startIcon={PrintIcon}
records.length > 0 ? ( onClick={handlePrint}
<Button title="Tisk docházky"
variant="outlined" >
color="inherit" Tisk
startIcon={PrintIcon} </Button>
onClick={handlePrint} ) : undefined
title="Tisk docházky" }
> />
Tisk
</Button>
) : undefined
}
/>
</motion.div>
{/* Filters */} {/* Filters */}
<motion.div <FilterBar>
initial={{ opacity: 0, y: 12 }} <Box sx={{ flex: "1 1 200px" }}>
animate={{ opacity: 1, y: 0 }} <Field label="Měsíc">
transition={{ duration: 0.25, delay: 0.06 }} <MonthField value={month} onChange={(val) => setMonth(val)} />
> </Field>
<FilterBar> </Box>
<Box sx={{ flex: "1 1 200px" }}> </FilterBar>
<Field label="Měsíc">
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Field>
</Box>
</FilterBar>
</motion.div>
{/* Monthly Fund Card */} {/* Monthly Fund Card */}
<motion.div <Card sx={{ mt: 3 }}>
initial={{ opacity: 0, y: 12 }} {isPending ? (
animate={{ opacity: 1, y: 0 }} <LoadingState />
transition={{ duration: 0.25, delay: 0.08 }} ) : (
> <Box
<Card sx={{ mt: 3 }}> sx={{
{isPending ? ( display: "flex",
<LoadingState /> alignItems: "center",
) : ( gap: 2,
flexWrap: "wrap",
}}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 2, justifyContent: "center",
flexWrap: "wrap", width: 44,
height: 44,
borderRadius: 2,
bgcolor: "info.light",
color: "info.main",
flexShrink: 0,
}} }}
> >
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</Box>
<Box sx={{ flex: 1, minWidth: "200px" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", justifyContent: "space-between",
justifyContent: "center", alignItems: "baseline",
width: 44, mb: 0.75,
height: 44,
borderRadius: 2,
bgcolor: "info.light",
color: "info.main",
flexShrink: 0,
}} }}
> >
<svg <Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
width="24" Fond: {fund.worked}h / {fund.fund}h
height="24" </Typography>
viewBox="0 0 24 24" <Typography
fill="none" variant="body2"
stroke="currentColor" color="text.secondary"
strokeWidth="2" sx={{ fontSize: "0.8125rem" }}
> >
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> {fund.business_days} prac. dnů
<line x1="16" y1="2" x2="16" y2="6" /> {fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
<line x1="8" y1="2" x2="8" y2="6" /> </Typography>
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</Box> </Box>
<Box sx={{ flex: 1, minWidth: "200px" }}> <ProgressBar value={progressValue} color={progressColor} />
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
justifyContent: "space-between", flexWrap: "wrap",
alignItems: "baseline", gap: 0.5,
mb: 0.75, mt: 1,
}} minHeight: "1.5rem",
> alignItems: "center",
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}> justifyContent: "space-between",
Fond: {fund.worked}h / {fund.fund}h }}
</Typography> >
<Typography <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
variant="body2" {computed.totalMinutes > 0 && (
color="text.secondary" <StatusChip
sx={{ fontSize: "0.8125rem" }} color="info"
> label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
{fund.business_days} prac. dnů title="Odpracováno (reálně)"
{fund.holiday_count > 0 && />
` + ${fund.holiday_count} svátky`} )}
</Typography> {computed.vacationHours > 0 && (
<StatusChip
color="success"
label={`Dov: ${computed.vacationHours}h`}
/>
)}
{computed.sickHours > 0 && (
<StatusChip
color="error"
label={`Nem: ${computed.sickHours}h`}
/>
)}
{computed.freeHolidayHours > 0 && (
<StatusChip
color="warning"
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
/>
)}
{computed.unpaidHours > 0 && (
<StatusChip
color="default"
label={`Nep: ${computed.unpaidHours}h`}
/>
)}
</Box> </Box>
<ProgressBar value={progressValue} color={progressColor} /> <Typography
<Box variant="caption"
sx={{ sx={{ fontWeight: 600, color: deltaColor }}
display: "flex",
flexWrap: "wrap",
gap: 0.5,
mt: 1,
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}}
> >
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}> {computed.delta > 0
{computed.totalMinutes > 0 && ( ? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
<StatusChip : computed.delta < 0
color="info" ? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`} : "Fond splněn"}
title="Odpracováno (reálně)" </Typography>
/>
)}
{computed.vacationHours > 0 && (
<StatusChip
color="success"
label={`Dov: ${computed.vacationHours}h`}
/>
)}
{computed.sickHours > 0 && (
<StatusChip
color="error"
label={`Nem: ${computed.sickHours}h`}
/>
)}
{computed.freeHolidayHours > 0 && (
<StatusChip
color="warning"
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
/>
)}
{computed.unpaidHours > 0 && (
<StatusChip
color="default"
label={`Nep: ${computed.unpaidHours}h`}
/>
)}
</Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: deltaColor }}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</Typography>
</Box>
</Box> </Box>
</Box> </Box>
)} </Box>
</Card> )}
</motion.div> </Card>
{/* Records Table */} {/* Records Table */}
<motion.div <Card sx={{ mt: 3 }}>
initial={{ opacity: 0, y: 12 }} {isPending ? (
animate={{ opacity: 1, y: 0 }} <LoadingState />
transition={{ duration: 0.25, delay: 0.12 }} ) : (
> <DataTable<AttendanceRecord>
<Card sx={{ mt: 3 }}> columns={columns}
{isPending ? ( rows={records}
<LoadingState /> rowKey={(record) => record.id}
) : ( empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
<DataTable<AttendanceRecord> />
columns={columns} )}
rows={records} </Card>
rowKey={(record) => record.id}
empty={
<EmptyState title="Za tento měsíc nejsou žádné záznamy." />
}
/>
)}
</Card>
</motion.div>
{/* Hidden Print Content */} {/* Hidden Print Content */}
{records.length > 0 && ( {records.length > 0 && (
@@ -888,7 +861,7 @@ export default function AttendanceHistory() {
</table> </table>
</div> </div>
)} )}
</Box> </PageEnter>
); );
} }

View File

@@ -4,7 +4,6 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom"; import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -16,7 +15,7 @@ import {
attendanceLocationOptions, attendanceLocationOptions,
type LocationRecord, type LocationRecord,
} from "../lib/queries/attendance"; } from "../lib/queries/attendance";
import { Button, Card, LoadingState, PageHeader } from "../ui"; import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
const BackIcon = ( const BackIcon = (
<svg <svg
@@ -187,79 +186,127 @@ export default function AttendanceLocation() {
} }
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Poloha záznamu"
animate={{ opacity: 1, y: 0 }} actions={
transition={{ duration: 0.25 }} <Button
> component={RouterLink}
<PageHeader to={`/attendance/admin?month=${month}`}
title="Poloha záznamu" variant="outlined"
actions={ color="inherit"
<Button startIcon={BackIcon}
component={RouterLink} >
to={`/attendance/admin?month=${month}`} Zpět na správu
variant="outlined" </Button>
color="inherit" }
startIcon={BackIcon} />
>
Zpět na správu
</Button>
}
/>
</motion.div>
{/* Info + map card */} {/* Info + map card */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} {record.user_name} {formatDate(record.shift_date)}
transition={{ duration: 0.25, delay: 0.06 }} </Typography>
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
{record.user_name} {formatDate(record.shift_date)}
</Typography>
{/* Leaflet map — kept entirely as-is */} {/* Leaflet map — kept entirely as-is */}
{hasAnyLocation && ( {hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" /> <div ref={mapRef} className="attendance-location-map" />
)} )}
{/* Location detail grid */} {/* Location detail grid */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
mt: hasAnyLocation ? 2 : 0,
}}
>
{/* Arrival */}
<Box <Box
sx={{ sx={{
display: "grid", p: 2,
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, borderRadius: 1,
gap: 2, border: "1px solid",
mt: hasAnyLocation ? 2 : 0, borderColor: "divider",
opacity: hasArrivalLocation ? 1 : 0.6,
}} }}
> >
{/* Arrival */} <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Příchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</Typography>
{hasArrivalLocation ? (
<>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.arrival_address || <em>Adresa nezjištěna</em>}
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</Button>
</>
) : (
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</Typography>
)}
</Box>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<Box <Box
sx={{ sx={{
p: 2, p: 2,
borderRadius: 1, borderRadius: 1,
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
opacity: hasArrivalLocation ? 1 : 0.6, opacity: hasDepartureLocation ? 1 : 0.6,
}} }}
> >
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Příchod Odchod
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }} sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
> >
{record.arrival_time {record.departure_time
? formatDatetimeLocal(record.arrival_time) ? formatDatetimeLocal(record.departure_time)
: "—"} : "—"}
</Typography> </Typography>
{hasArrivalLocation ? ( {hasDepartureLocation ? (
<> <>
<Typography variant="body2" sx={{ mb: 0.5 }}> <Typography variant="body2" sx={{ mb: 0.5 }}>
{record.arrival_address || <em>Adresa nezjištěna</em>} {record.departure_address || <em>Adresa nezjištěna</em>}
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
@@ -270,13 +317,13 @@ export default function AttendanceLocation() {
mb: 1, mb: 1,
}} }}
> >
GPS: {record.arrival_lat}, {record.arrival_lng} GPS: {record.departure_lat}, {record.departure_lng}
{record.arrival_accuracy && {record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`} ` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</Typography> </Typography>
<Button <Button
component="a" component="a"
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`} href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
variant="outlined" variant="outlined"
@@ -292,69 +339,9 @@ export default function AttendanceLocation() {
</Typography> </Typography>
)} )}
</Box> </Box>
)}
{/* Departure */} </Box>
{(hasDepartureLocation || record.departure_time) && ( </Card>
<Box </PageEnter>
sx={{
p: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
opacity: hasDepartureLocation ? 1 : 0.6,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Odchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</Typography>
{hasDepartureLocation ? (
<>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.departure_address || <em>Adresa nezjištěna</em>}
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</Button>
</>
) : (
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</Typography>
)}
</Box>
)}
</Box>
</Card>
</motion.div>
</Box>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -21,6 +20,7 @@ import {
TextField, TextField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -267,30 +267,24 @@ export default function AuditLog() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Audit log"
animate={{ opacity: 1, y: 0 }} subtitle={
transition={{ duration: 0.25 }} pagination
> ? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
<PageHeader : undefined
title="Audit log" }
subtitle={ actions={
pagination <Button
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}` variant="outlined"
: undefined startIcon={TrashIcon}
} onClick={() => setShowCleanup(true)}
actions={ >
<Button Vyčistit
variant="outlined" </Button>
startIcon={TrashIcon} }
onClick={() => setShowCleanup(true)} />
>
Vyčistit
</Button>
}
/>
</motion.div>
{/* Cleanup Modal */} {/* Cleanup Modal */}
<Modal <Modal
@@ -371,6 +365,6 @@ export default function AuditLog() {
onChange={setPage} onChange={setPage}
/> />
</Card> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -12,7 +11,7 @@ import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings"; import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers"; import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip } from "../ui"; import { Card, Button, StatusChip, PageEnter } from "../ui";
import DashKpiCards from "../components/dashboard/DashKpiCards"; import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions"; import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed"; import DashActivityFeed from "../components/dashboard/DashActivityFeed";
@@ -221,15 +220,9 @@ export default function Dashboard() {
}; };
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<Box <Box sx={{ mb: 3 }}>
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
sx={{ mb: 3 }}
>
<Typography variant="h4"> <Typography variant="h4">
Vítejte zpět, {user?.fullName || user?.username} Vítejte zpět, {user?.fullName || user?.username}
</Typography> </Typography>
@@ -240,75 +233,69 @@ export default function Dashboard() {
{/* 2FA Required Banner */} {/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && ( {user?.require2FA && !user?.totpEnabled && (
<motion.div <Card
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} mb: 3,
transition={{ duration: 0.25, delay: 0.06 }} border: 2,
borderColor: "error.main",
bgcolor: "error.light",
}}
> >
<Card <Box
sx={{ sx={{
mb: 3, display: "flex",
border: 2, alignItems: "center",
borderColor: "error.main", justifyContent: "space-between",
bgcolor: "error.light", gap: 2,
flexWrap: "wrap",
}} }}
> >
<Box <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
sx={{ <Box
display: "flex", sx={{
alignItems: "center", width: 40,
justifyContent: "space-between", height: 40,
gap: 2, borderRadius: "50%",
flexWrap: "wrap", display: "flex",
}} alignItems: "center",
> justifyContent: "center",
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> bgcolor: "error.light",
<Box color: "error.main",
sx={{ flexShrink: 0,
width: 40, }}
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "error.light",
color: "error.main",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</Box>
<Box>
<Typography sx={{ fontWeight: 600 }}>
Dvoufaktorové ověření je povinné
</Typography>
<Typography variant="body2" color="text.secondary">
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</Typography>
</Box>
</Box>
<Button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
sx={{ flexShrink: 0 }}
> >
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"} <svg
</Button> width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</Box>
<Box>
<Typography sx={{ fontWeight: 600 }}>
Dvoufaktorové ověření je povinné
</Typography>
<Typography variant="body2" color="text.secondary">
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</Typography>
</Box>
</Box> </Box>
</Card> <Button
</motion.div> onClick={handleStart2FASetup}
disabled={totpSubmitting}
sx={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</Button>
</Box>
</Card>
)} )}
{/* Loading spinner */} {/* Loading spinner */}
@@ -339,10 +326,6 @@ export default function Dashboard() {
{/* Main content grid */} {/* Main content grid */}
{!dashLoading && ( {!dashLoading && (
<Box <Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" }, gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
@@ -523,6 +506,6 @@ export default function Dashboard() {
<DashSessions /> <DashSessions />
</Box> </Box>
)} )}
</Box> </PageEnter>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link as RouterLink, useSearchParams } from "react-router-dom"; import { Link as RouterLink, useSearchParams } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -35,6 +34,7 @@ import {
FilterBar, FilterBar,
Tabs, Tabs,
PageHeader, PageHeader,
PageEnter,
type DataColumn, type DataColumn,
type TabDef, type TabDef,
type StatCardColor, type StatCardColor,
@@ -644,43 +644,33 @@ export default function Invoices() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Faktury"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> hasPermission("invoices.create") ? (
<PageHeader activeTab === "received" ? (
title="Faktury" <Button
subtitle={subtitle} startIcon={UploadIcon}
actions={ onClick={() => setReceivedUploadOpen(true)}
hasPermission("invoices.create") ? ( >
activeTab === "received" ? ( Nahrát faktury
<Button </Button>
startIcon={UploadIcon} ) : (
onClick={() => setReceivedUploadOpen(true)} <Button
> component={RouterLink}
Nahrát faktury to="/invoices/new"
</Button> startIcon={PlusIcon}
) : ( >
<Button Nová faktura
component={RouterLink} </Button>
to="/invoices/new" )
startIcon={PlusIcon} ) : undefined
> }
Nová faktura />
</Button>
)
) : undefined
}
/>
</motion.div>
<motion.div <Box>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -722,49 +712,31 @@ export default function Invoices() {
]} ]}
/> />
</Box> </Box>
</motion.div> </Box>
{activeTab === "received" ? ( {activeTab === "received" ? (
<motion.div <Suspense fallback={<LoadingState />}>
initial={{ opacity: 0, y: 12 }} <ReceivedInvoices
animate={{ opacity: 1, y: 0 }} statsMonth={statsMonth}
transition={{ duration: 0.25, delay: 0.1 }} statsYear={statsYear}
> uploadOpen={receivedUploadOpen}
<Suspense fallback={<LoadingState />}> setUploadOpen={setReceivedUploadOpen}
<ReceivedInvoices />
statsMonth={statsMonth} </Suspense>
statsYear={statsYear}
uploadOpen={receivedUploadOpen}
setUploadOpen={setReceivedUploadOpen}
/>
</Suspense>
</motion.div>
) : ( ) : (
<> <>
<motion.div {renderKpi()}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
{renderKpi()}
</motion.div>
<motion.div <Box sx={{ mb: 2.5 }}>
initial={{ opacity: 0, y: 12 }} <Tabs
animate={{ opacity: 1, y: 0 }} value={statusFilter}
transition={{ duration: 0.25, delay: 0.12 }} onChange={(v) => {
> setStatusFilter(v);
<Box sx={{ mb: 2.5 }}> setPage(1);
<Tabs }}
value={statusFilter} tabs={statusTabs}
onChange={(v) => { />
setStatusFilter(v); </Box>
setPage(1);
}}
tabs={statusTabs}
/>
</Box>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
@@ -845,40 +817,32 @@ export default function Invoices() {
</Box> </Box>
)} )}
<motion.div <Card sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }}>
initial={{ opacity: 0, y: 12 }} <DataTable<Invoice>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.15 }} rows={invoices}
> rowKey={(inv) => inv.id}
<Card rowSx={rowSx}
sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }} sortBy={sort}
> sortDir={order}
<DataTable<Invoice> onSort={handleSort}
columns={columns} empty={
rows={invoices} <EmptyState
rowKey={(inv) => inv.id} title="Zatím nejsou žádné faktury."
rowSx={rowSx} description={
sortBy={sort} hasPermission("invoices.create")
sortDir={order} ? "Vytvořte první fakturu tlačítkem výše."
onSort={handleSort} : undefined
empty={ }
<EmptyState />
title="Zatím nejsou žádné faktury." }
description={ />
hasPermission("invoices.create") <Pagination
? "Vytvořte první fakturu tlačítkem výše." page={page}
: undefined pageCount={pagination?.total_pages ?? 1}
} onChange={setPage}
/> />
} </Card>
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</motion.div>
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.show} isOpen={deleteConfirm.show}
@@ -893,6 +857,6 @@ export default function Invoices() {
/> />
</> </>
)} )}
</Box> </PageEnter>
); );
} }

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers"; import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
@@ -23,6 +22,7 @@ import {
Field, Field,
TextField, TextField,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
Tabs, Tabs,
@@ -304,33 +304,21 @@ export default function LeaveApproval() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Schvalování nepřítomnosti"
animate={{ opacity: 1, y: 0 }} subtitle={
transition={{ duration: 0.25 }} pendingCount > 0
> ? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
<PageHeader : "Žádné čekající žádosti"
title="Schvalování nepřítomnosti" }
subtitle={ />
pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"
}
/>
</motion.div>
<motion.div <Tabs
initial={{ opacity: 0, y: 12 }} value={activeTab}
animate={{ opacity: 1, y: 0 }} onChange={(v) => setActiveTab(v as "pending" | "processed")}
transition={{ duration: 0.25, delay: 0.06 }} tabs={tabs}
> />
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "pending" | "processed")}
tabs={tabs}
/>
</motion.div>
{/* Pending Tab */} {/* Pending Tab */}
<TabPanel value="pending" current={activeTab}> <TabPanel value="pending" current={activeTab}>
@@ -498,6 +486,6 @@ export default function LeaveApproval() {
</> </>
)} )}
</Modal> </Modal>
</Box> </PageEnter>
); );
} }

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers"; import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
@@ -15,6 +14,7 @@ import {
ConfirmDialog, ConfirmDialog,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
@@ -237,37 +237,25 @@ export default function LeaveRequests() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Moje žádosti"
animate={{ opacity: 1, y: 0 }} subtitle="Přehled žádostí o nepřítomnost"
transition={{ duration: 0.25 }} />
>
<PageHeader
title="Moje žádosti"
subtitle="Přehled žádostí o nepřítomnost"
/>
</motion.div>
<motion.div <Card>
initial={{ opacity: 0, y: 12 }} <DataTable<LeaveRequest>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.06 }} rows={requests}
> rowKey={(req) => req.id}
<Card> empty={
<DataTable<LeaveRequest> <EmptyState
columns={columns} title="Zatím nemáte žádné žádosti"
rows={requests} description="Novou žádost můžete podat na stránce Docházka"
rowKey={(req) => req.id} />
empty={ }
<EmptyState />
title="Zatím nemáte žádné žádosti" </Card>
description="Novou žádost můžete podat na stránce Docházka"
/>
}
/>
</Card>
</motion.div>
<ConfirmDialog <ConfirmDialog
isOpen={cancelModal.open} isOpen={cancelModal.open}
@@ -278,6 +266,6 @@ export default function LeaveRequests() {
confirmText="Zrušit žádost" confirmText="Zrušit žádost"
loading={cancelMutation.isPending} loading={cancelMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom"; import { Link as RouterLink, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -34,6 +33,7 @@ import {
FilterBar, FilterBar,
Tabs, Tabs,
PageHeader, PageHeader,
PageEnter,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
type TabDef, type TabDef,
@@ -686,58 +686,46 @@ export default function Offers() {
}; };
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Nabídky"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> <>
<PageHeader {hasPermission("settings.templates") && (
title="Nabídky" <Button
subtitle={subtitle} component={RouterLink}
actions={ to="/offers/templates"
<> variant="outlined"
{hasPermission("settings.templates") && ( color="inherit"
<Button startIcon={TemplatesIcon}
component={RouterLink} >
to="/offers/templates" Šablony
variant="outlined" </Button>
color="inherit" )}
startIcon={TemplatesIcon} {hasPermission("offers.create") && (
> <Button
Šablony component={RouterLink}
</Button> to="/offers/new"
)} startIcon={PlusIcon}
{hasPermission("offers.create") && ( >
<Button Nová nabídka
component={RouterLink} </Button>
to="/offers/new" )}
startIcon={PlusIcon} </>
> }
Nová nabídka />
</Button>
)}
</>
}
/>
</motion.div>
<motion.div <Box sx={{ mb: 2.5 }}>
initial={{ opacity: 0, y: 12 }} <Tabs
animate={{ opacity: 1, y: 0 }} value={statusFilter}
transition={{ duration: 0.25, delay: 0.1 }} onChange={(v) => {
> setStatusFilter(v);
<Box sx={{ mb: 2.5 }}> setPage(1);
<Tabs }}
value={statusFilter} tabs={tabs}
onChange={(v) => { />
setStatusFilter(v); </Box>
setPage(1);
}}
tabs={tabs}
/>
</Box>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
@@ -838,44 +826,38 @@ export default function Offers() {
</Box> </Box>
)} )}
<motion.div <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
initial={{ opacity: 0, y: 12 }} <DataTable<Quotation>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.15 }} rows={quotations}
> rowKey={(q) => q.id}
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> rowSx={rowSx}
<DataTable<Quotation> sortBy={sort}
columns={columns} sortDir={order}
rows={quotations} onSort={handleSort}
rowKey={(q) => q.id} empty={
rowSx={rowSx} debouncedSearch ? (
sortBy={sort} <EmptyState title="Žádné nabídky odpovídající hledání." />
sortDir={order} ) : (
onSort={handleSort} <EmptyState
empty={ title="Zatím nejsou žádné nabídky."
debouncedSearch ? ( action={
<EmptyState title="Žádné nabídky odpovídající hledání." /> hasPermission("offers.create") ? (
) : ( <Button component={RouterLink} to="/offers/new">
<EmptyState Vytvořit první nabídku
title="Zatím nejsou žádné nabídky." </Button>
action={ ) : undefined
hasPermission("offers.create") ? ( }
<Button component={RouterLink} to="/offers/new"> />
Vytvořit první nabídku )
</Button> }
) : undefined />
} <Pagination
/> page={page}
) pageCount={pagination?.total_pages ?? 1}
} onChange={setPage}
/> />
<Pagination </Card>
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</motion.div>
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.show} isOpen={deleteConfirm.show}
@@ -935,6 +917,6 @@ export default function Offers() {
</Typography> </Typography>
</Field> </Field>
</Modal> </Modal>
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -24,6 +23,7 @@ import {
CheckboxField, CheckboxField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -468,24 +468,18 @@ export default function OffersCustomers() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Zákazníci"
animate={{ opacity: 1, y: 0 }} subtitle="Správa zákazníků pro nabídky"
transition={{ duration: 0.25 }} actions={
> hasPermission("customers.create") ? (
<PageHeader <Button startIcon={PlusIcon} onClick={openCreateModal}>
title="Zákazníci" Přidat zákazníka
subtitle="Správa zákazníků pro nabídky" </Button>
actions={ ) : undefined
hasPermission("customers.create") ? ( }
<Button startIcon={PlusIcon} onClick={openCreateModal}> />
Přidat zákazníka
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
@@ -816,6 +810,6 @@ export default function OffersCustomers() {
confirmVariant="danger" confirmVariant="danger"
loading={deleting} loading={deleting}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -27,6 +26,7 @@ import {
TextField, TextField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
Tabs, Tabs,
@@ -142,29 +142,17 @@ export default function OffersTemplates() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Šablony"
animate={{ opacity: 1, y: 0 }} subtitle="Šablony položek a rozsahu projektu"
transition={{ duration: 0.25 }} />
>
<PageHeader
title="Šablony"
subtitle="Šablony položek a rozsahu projektu"
/>
</motion.div>
<motion.div <Tabs
initial={{ opacity: 0, y: 12 }} value={activeTab}
animate={{ opacity: 1, y: 0 }} onChange={(v) => setActiveTab(v as "items" | "scopes")}
transition={{ duration: 0.25, delay: 0.06 }} tabs={tabs}
> />
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "items" | "scopes")}
tabs={tabs}
/>
</motion.div>
<TabPanel value="items" current={activeTab}> <TabPanel value="items" current={activeTab}>
<ItemTemplatesTab /> <ItemTemplatesTab />
@@ -172,7 +160,7 @@ export default function OffersTemplates() {
<TabPanel value="scopes" current={activeTab}> <TabPanel value="scopes" current={activeTab}>
<ScopeTemplatesTab /> <ScopeTemplatesTab />
</TabPanel> </TabPanel>
</Box> </PageEnter>
); );
} }

View File

@@ -13,7 +13,6 @@ import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import OrderConfirmationModal from "../components/OrderConfirmationModal"; import OrderConfirmationModal from "../components/OrderConfirmationModal";
@@ -31,6 +30,7 @@ import {
ConfirmDialog, ConfirmDialog,
EmptyState, EmptyState,
LoadingState, LoadingState,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -390,13 +390,9 @@ export default function OrderDetail() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <Box>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -498,339 +494,315 @@ export default function OrderDetail() {
)} )}
</Box> </Box>
</Box> </Box>
</motion.div> </Box>
{/* Info card */} {/* Info card */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Informace
transition={{ duration: 0.25, delay: 0.06 }} </Typography>
> <Box
<Card sx={{ mb: 3 }}> sx={{
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> display: "grid",
Informace gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
</Typography> gap: 2,
<Box }}
sx={{ >
display: "grid", <Box>
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" }, <Typography variant="caption" color="text.secondary">
gap: 2, Nabídka
}} </Typography>
> <Typography
<Box> variant="body2"
<Typography variant="caption" color="text.secondary"> sx={{
Nabídka fontFamily: "'DM Mono', Menlo, monospace",
</Typography> fontWeight: 500,
<Typography }}
variant="body2" >
<Box
component={RouterLink}
to={`/offers/${order.quotation_id}`}
sx={{ sx={{
fontFamily: "'DM Mono', Menlo, monospace", color: "primary.main",
fontWeight: 500, textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}} }}
> >
{order.quotation_number}
</Box>
{order.project_code && (
<Box component="span" sx={{ color: "text.secondary", ml: 1 }}>
({order.project_code})
</Box>
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{order.project ? (
<Box <Box
component={RouterLink} component={RouterLink}
to={`/offers/${order.quotation_id}`} to={`/projects/${order.project.id}`}
sx={{ sx={{
color: "primary.main", color: "primary.main",
textDecoration: "none", textDecoration: "none",
"&:hover": { textDecoration: "underline" }, "&:hover": { textDecoration: "underline" },
}} }}
> >
{order.quotation_number} {order.project.project_number} {order.project.name}
</Box> </Box>
{order.project_code && ( ) : (
<Box component="span" sx={{ color: "text.secondary", ml: 1 }}> "—"
({order.project_code}) )}
</Box> </Typography>
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{order.project ? (
<Box
component={RouterLink}
to={`/projects/${order.project.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{order.project.project_number} {order.project.name}
</Box>
) : (
"—"
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Zákazník
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{order.customer_name || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo obj. zákazníka
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.customer_order_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Měna
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.currency}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Datum vytvoření
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(order.created_at)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Příloha
</Typography>
<Box sx={{ mt: 0.5 }}>
{order.attachment_name ? (
<Button
variant="outlined"
color="inherit"
startIcon={FileIcon}
onClick={handleViewAttachment}
disabled={attachmentLoading}
>
{order.attachment_name}
</Button>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
</Box>
</Box> </Box>
</Card> <Box>
</motion.div> <Typography variant="caption" color="text.secondary">
Zákazník
{/* Items (read-only) */} </Typography>
<motion.div <Typography
initial={{ opacity: 0, y: 12 }} variant="body2"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<ItemRow>
columns={itemColumns}
rows={itemRows}
rowKey={(item) => item.id ?? item._index}
empty={<EmptyState title="Žádné položky." />}
/>
{/* Totals */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.subtotal, order.currency)}
</Typography>
</Box>
{Number(order.apply_vat) > 0 && (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
DPH ({order.vat_rate}%):
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.vatAmount, order.currency)}
</Typography>
</Box>
)}
<Box
sx={{ sx={{
display: "flex", fontFamily: "'DM Mono', Menlo, monospace",
justifyContent: "space-between", fontWeight: 500,
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}} }}
> >
<Typography variant="body2" sx={{ fontWeight: 700 }}> {order.customer_name || "—"}
Celkem k úhradě: </Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo obj. zákazníka
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.customer_order_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Měna
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{order.currency}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Datum vytvoření
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(order.created_at)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Příloha
</Typography>
<Box sx={{ mt: 0.5 }}>
{order.attachment_name ? (
<Button
variant="outlined"
color="inherit"
startIcon={FileIcon}
onClick={handleViewAttachment}
disabled={attachmentLoading}
>
{order.attachment_name}
</Button>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
</Box>
</Box>
</Card>
{/* Items (read-only) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<ItemRow>
columns={itemColumns}
rows={itemRows}
rowKey={(item) => item.id ?? item._index}
empty={<EmptyState title="Žádné položky." />}
/>
{/* Totals */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.subtotal, order.currency)}
</Typography>
</Box>
{Number(order.apply_vat) > 0 && (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
DPH ({order.vat_rate}%):
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
> >
{formatCurrency(totals.total, order.currency)} {formatCurrency(totals.vatAmount, order.currency)}
</Typography> </Typography>
</Box> </Box>
)}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem k úhradě:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(totals.total, order.currency)}
</Typography>
</Box> </Box>
</Card> </Box>
</motion.div> </Card>
{/* Sections (read-only) */} {/* Sections (read-only) */}
{order.sections?.length > 0 && ( {order.sections?.length > 0 && (
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Rozsah projektu
transition={{ duration: 0.25, delay: 0.15 }} </Typography>
> {order.scope_title && (
<Card sx={{ mb: 3 }}> <Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> {order.scope_title}
Rozsah projektu
</Typography> </Typography>
{order.scope_title && ( )}
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}> {order.scope_description && (
{order.scope_title} <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
</Typography> {order.scope_description}
)} </Typography>
{order.scope_description && ( )}
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{order.scope_description} {order.sections.map((section, index) => (
</Typography> <Box
)} key={section.id || index}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> sx={{
{order.sections.map((section, index) => ( border: 1,
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
}}
>
<Box <Box
key={section.id || index}
sx={{ sx={{
border: 1, display: "flex",
borderColor: "divider", alignItems: "center",
borderRadius: 2, gap: 1,
overflow: "hidden", px: 2,
py: 1,
bgcolor: "action.hover",
}} }}
> >
<Box <Typography
sx={{ variant="body2"
display: "flex", color="text.secondary"
alignItems: "center", sx={{ fontWeight: 600 }}
gap: 1,
px: 2,
py: 1,
bgcolor: "action.hover",
}}
> >
<Typography {index + 1}.
variant="body2" </Typography>
color="text.secondary" <Typography variant="body2" sx={{ fontWeight: 600 }}>
sx={{ fontWeight: 600 }} {(order.language === "CZ"
> ? section.title_cz || section.title
{index + 1}. : section.title || section.title_cz) ||
</Typography> `Sekce ${index + 1}`}
<Typography variant="body2" sx={{ fontWeight: 600 }}> </Typography>
{(order.language === "CZ"
? section.title_cz || section.title
: section.title || section.title_cz) ||
`Sekce ${index + 1}`}
</Typography>
</Box>
{section.content && (
<Box
className="admin-rich-text-view"
sx={{ p: 2 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content),
}}
/>
)}
</Box> </Box>
))} {section.content && (
</Box> <Box
</Card> className="admin-rich-text-view"
</motion.div> sx={{ p: 2 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content),
}}
/>
)}
</Box>
))}
</Box>
</Card>
)} )}
{/* Notes (editable) */} {/* Notes (editable) */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Poznámky
transition={{ duration: 0.25, delay: 0.2 }} </Typography>
> <Field label="Poznámky">
<Card sx={{ mb: 3 }}> <TextField
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> value={notes}
Poznámky onChange={(e) => setNotes(e.target.value)}
</Typography> multiline
<Field label="Poznámky"> minRows={4}
<TextField placeholder="Interní poznámky k objednávce..."
value={notes} fullWidth
onChange={(e) => setNotes(e.target.value)} disabled={!hasPermission("orders.edit")}
multiline />
minRows={4} </Field>
placeholder="Interní poznámky k objednávce..." {hasPermission("orders.edit") && (
fullWidth <Box sx={{ mt: 1 }}>
disabled={!hasPermission("orders.edit")} <Button
/> variant="outlined"
</Field> color="inherit"
{hasPermission("orders.edit") && ( onClick={handleSaveNotes}
<Box sx={{ mt: 1 }}> disabled={saving}
<Button >
variant="outlined" {saving ? "Ukládání..." : "Uložit poznámky"}
color="inherit" </Button>
onClick={handleSaveNotes} </Box>
disabled={saving} )}
> </Card>
{saving ? "Ukládání..." : "Uložit poznámky"}
</Button>
</Box>
)}
</Card>
</motion.div>
{/* Status change confirmation */} {/* Status change confirmation */}
<ConfirmDialog <ConfirmDialog
@@ -887,6 +859,6 @@ export default function OrderDetail() {
applyVat={!!order.apply_vat} applyVat={!!order.apply_vat}
/> />
)} )}
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom"; import { Link as RouterLink, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -32,6 +31,7 @@ import {
CheckboxField, CheckboxField,
FileUpload, FileUpload,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -463,24 +463,18 @@ export default function Orders() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Objednávky"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> hasPermission("orders.create") ? (
<PageHeader <Button startIcon={PlusIcon} onClick={openCreate}>
title="Objednávky" Vytvořit objednávku
subtitle={subtitle} </Button>
actions={ ) : undefined
hasPermission("orders.create") ? ( }
<Button startIcon={PlusIcon} onClick={openCreate}> />
Vytvořit objednávku
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
@@ -496,33 +490,27 @@ export default function Orders() {
</Box> </Box>
</FilterBar> </FilterBar>
<motion.div <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
initial={{ opacity: 0, y: 12 }} <DataTable<Order>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.06 }} rows={orders}
> rowKey={(o) => o.id}
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> sortBy={sort}
<DataTable<Order> sortDir={order}
columns={columns} onSort={handleSort}
rows={orders} empty={
rowKey={(o) => o.id} <EmptyState
sortBy={sort} title="Zatím nejsou žádné objednávky."
sortDir={order} description="Objednávky se vytvářejí z nabídek."
onSort={handleSort} />
empty={ }
<EmptyState />
title="Zatím nejsou žádné objednávky." <Pagination
description="Objednávky se vytvářejí z nabídek." page={page}
/> pageCount={pagination?.total_pages ?? 1}
} onChange={setPage}
/> />
<Pagination </Card>
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</motion.div>
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.show} isOpen={deleteConfirm.show}
@@ -711,6 +699,6 @@ export default function Orders() {
onChange={(v) => setCreateForm({ ...createForm, create_project: v })} onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
/> />
</Modal> </Modal>
</Box> </PageEnter>
); );
} }

View File

@@ -15,7 +15,7 @@ import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal"; import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal"; import PlanCategoriesModal from "../components/PlanCategoriesModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Button, Alert, LoadingState } from "../ui"; import { Button, Alert, LoadingState, PageEnter } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects"; import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan"; import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import. // plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
@@ -545,130 +545,107 @@ export default function PlanWork() {
} }
return ( return (
<Box className="plan-work-page"> <PageEnter sx={{ position: "relative" }}>
<motion.div <Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
initial={{ opacity: 0, y: 12 }} Plán prací
animate={{ opacity: 1, y: 0 }} </Typography>
transition={{ duration: 0.25 }}
>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Plán prací
</Typography>
</motion.div>
{!canEdit && ( {!canEdit && (
<motion.div <Box role="status" sx={{ mb: 2 }}>
role="status" <Alert severity="info">
initial={{ opacity: 0, y: 12 }} <strong>Režim náhledu</strong> můžete pouze prohlížet plán. Úpravy
animate={{ opacity: 1, y: 0 }} jsou dostupné oprávněným uživatelům.
transition={{ duration: 0.25, delay: 0.06 }} </Alert>
> </Box>
<Box sx={{ mb: 2 }}>
<Alert severity="info">
<strong>Režim náhledu</strong> můžete pouze prohlížet plán.
Úpravy jsou dostupné oprávněným uživatelům.
</Alert>
</Box>
</motion.div>
)} )}
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25, delay: 0.12 }} flexWrap: "wrap",
alignItems: "center",
gap: 1.5,
mb: 2,
}}
> >
{/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}>
Dnes
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goPrev}
aria-label="Předchozí"
>
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goNext}
aria-label="Další"
>
</Button>
</Box>
<Box <Box
sx={{ sx={{
flex: 1,
minWidth: 220,
display: "flex", display: "flex",
flexWrap: "wrap", justifyContent: "center",
alignItems: "center",
gap: 1.5,
mb: 2,
}} }}
> >
{/* Nav buttons grouped so they stay on one row when the toolbar <AnimatePresence mode="popLayout" initial={false}>
wraps on mobile. */} <motion.div
<Box sx={{ display: "inline-flex", gap: 1 }}> key={`${isoDate(range.from)}-${view}`}
<Button variant="outlined" color="inherit" onClick={goToToday}> initial={{ opacity: 0 }}
Dnes animate={{ opacity: 1 }}
</Button> exit={{ opacity: 0 }}
<Button transition={{ duration: 0.15 }}
variant="outlined"
color="inherit"
onClick={goPrev}
aria-label="Předchozí"
> >
<Typography
</Button> variant="h6"
<Button sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
variant="outlined"
color="inherit"
onClick={goNext}
aria-label="Další"
>
</Button>
</Box>
<Box
sx={{
flex: 1,
minWidth: 220,
display: "flex",
justifyContent: "center",
}}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${isoDate(range.from)}-${view}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
> >
<Typography {formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
variant="h6" </Typography>
sx={{ fontWeight: 600, whiteSpace: "nowrap" }} </motion.div>
> </AnimatePresence>
{formatRangeLabel(
isoDate(range.from),
isoDate(range.to),
view,
)}
</Typography>
</motion.div>
</AnimatePresence>
</Box>
<Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
>
<Button
variant={view === "week" ? "contained" : "outlined"}
color={view === "week" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("week")}
>
Týden
</Button>
<Button
variant={view === "month" ? "contained" : "outlined"}
color={view === "month" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("month")}
>
Měsíc
</Button>
</Box>
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</Button>
)}
</Box> </Box>
</motion.div> <Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
>
<Button
variant={view === "week" ? "contained" : "outlined"}
color={view === "week" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("week")}
>
Týden
</Button>
<Button
variant={view === "month" ? "contained" : "outlined"}
color={view === "month" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("month")}
>
Měsíc
</Button>
</Box>
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</Button>
)}
</Box>
{gridLoading && <LoadingState />} {gridLoading && <LoadingState />}
@@ -689,20 +666,14 @@ export default function PlanWork() {
patch the cache in place and pulse a single cell via pulseKey. patch the cache in place and pulse a single cell via pulseKey.
*/} */}
{grid ? ( {grid ? (
<motion.div <PlanGrid
initial={{ opacity: 0, y: 12 }} data={grid}
animate={{ opacity: 1, y: 0 }} projects={projects}
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }} categories={categories}
> canEdit={canEdit}
<PlanGrid pulseKey={lastMutated}
data={grid} onCellClick={openCell}
projects={projects} />
categories={categories}
canEdit={canEdit}
pulseKey={lastMutated}
onCellClick={openCell}
/>
</motion.div>
) : null} ) : null}
<PlanCellModal <PlanCellModal
@@ -726,6 +697,6 @@ export default function PlanWork() {
onClose={() => setCatModalOpen(false)} onClose={() => setCatModalOpen(false)}
categories={categories} categories={categories}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -4,7 +4,6 @@ import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -29,6 +28,7 @@ import {
CheckboxField, CheckboxField,
ConfirmDialog, ConfirmDialog,
LoadingState, LoadingState,
PageEnter,
} from "../ui"; } from "../ui";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -286,13 +286,9 @@ export default function ProjectDetail() {
const canEdit = hasPermission("projects.edit"); const canEdit = hasPermission("projects.edit");
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <Box>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -347,324 +343,301 @@ export default function ProjectDetail() {
</Box> </Box>
)} )}
</Box> </Box>
</motion.div> </Box>
{/* Basic info form */} {/* Basic info form */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Základní údaje
transition={{ duration: 0.25, delay: 0.06 }} </Typography>
> <Box
<Card sx={{ mb: 3 }}> sx={{
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> display: "grid",
Základní údaje gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
</Typography> gap: 2,
<Box }}
sx={{ >
display: "grid", <Field label="Číslo projektu">
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, <TextField value={project.project_number} fullWidth disabled />
gap: 2, </Field>
}} <Field label="Název">
> <TextField
<Field label="Číslo projektu"> value={form.name}
<TextField value={project.project_number} fullWidth disabled /> onChange={(e) => updateForm("name", e.target.value)}
</Field> placeholder="Název projektu"
<Field label="Název"> fullWidth
<TextField disabled={!canEdit}
value={form.name} />
onChange={(e) => updateForm("name", e.target.value)} </Field>
placeholder="Název projektu" <Field label="Zákazník">
fullWidth <TextField
disabled={!canEdit} value={project.customer_name || "—"}
/> fullWidth
</Field> disabled
<Field label="Zákazník"> />
<TextField </Field>
value={project.customer_name || "—"} <Field label="Zodpovědná osoba">
fullWidth <Select
disabled value={form.responsible_user_id}
/> onChange={(v) => updateForm("responsible_user_id", v)}
</Field> disabled={!canEdit}
<Field label="Zodpovědná osoba"> >
<Select <MenuItem value=""> Nevybráno </MenuItem>
value={form.responsible_user_id} {users.map((u) => (
onChange={(v) => updateForm("responsible_user_id", v)} <MenuItem key={u.id} value={String(u.id)}>
disabled={!canEdit} {u.name}
> </MenuItem>
<MenuItem value=""> Nevybráno </MenuItem> ))}
{users.map((u) => ( </Select>
<MenuItem key={u.id} value={String(u.id)}> </Field>
{u.name} </Box>
</MenuItem> <Box
))} sx={{
</Select> display: "grid",
</Field> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
</Box> gap: 2,
<Box }}
sx={{ >
display: "grid", <Field label="Stav">
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" }, <Select
gap: 2, value={form.status}
}} onChange={(v) => updateForm("status", v)}
> disabled={!canEdit}
<Field label="Stav"> >
<Select <MenuItem value="aktivni">Aktivní</MenuItem>
value={form.status} <MenuItem value="dokonceny">Dokončený</MenuItem>
onChange={(v) => updateForm("status", v)} <MenuItem value="zruseny">Zrušený</MenuItem>
disabled={!canEdit} </Select>
> </Field>
<MenuItem value="aktivni">Aktivní</MenuItem> <Field label="Datum zahájení">
<MenuItem value="dokonceny">Dokončený</MenuItem> <DateField
<MenuItem value="zruseny">Zrušený</MenuItem> value={form.start_date}
</Select> onChange={(val) => updateForm("start_date", val)}
</Field> disabled={!canEdit}
<Field label="Datum zahájení"> />
<DateField </Field>
value={form.start_date} <Field label="Datum ukončení">
onChange={(val) => updateForm("start_date", val)} <DateField
disabled={!canEdit} value={form.end_date}
/> onChange={(val) => updateForm("end_date", val)}
</Field> disabled={!canEdit}
<Field label="Datum ukončení"> />
<DateField </Field>
value={form.end_date} </Box>
onChange={(val) => updateForm("end_date", val)} </Card>
disabled={!canEdit}
/>
</Field>
</Box>
</Card>
</motion.div>
{/* Notes */} {/* Notes */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Poznámky
transition={{ duration: 0.25, delay: 0.08 }} </Typography>
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
{/* Add note */} {/* Add note */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<TextField <TextField
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
multiline multiline
minRows={2} minRows={2}
placeholder="Napište poznámku..." placeholder="Napište poznámku..."
fullWidth fullWidth
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) { if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote(); handleAddNote();
} }
}} }}
/> />
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
onClick={handleAddNote} onClick={handleAddNote}
disabled={addingNote || !newNote.trim()} disabled={addingNote || !newNote.trim()}
>
{addingNote ? "Přidávání..." : "Přidat poznámku"}
</Button>
</Box>
</Box>
{/* Legacy notes (read-only) */}
{project.notes && (
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
mb: 1,
}}
> >
<Typography {addingNote ? "Přidávání..." : "Přidat poznámku"}
variant="caption" </Button>
color="text.secondary" </Box>
sx={{ display: "block", mb: 0.5 }} </Box>
>
Starší poznámka (před zavedením systému)
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: "pre-wrap" }}
>
{project.notes}
</Typography>
</Box>
)}
{/* Notes list */} {/* Legacy notes (read-only) */}
{notes.length === 0 && !project.notes && ( {project.notes && (
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
mb: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Starší poznámka (před zavedením systému)
</Typography>
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ textAlign: "center", py: 2 }} sx={{ whiteSpace: "pre-wrap" }}
> >
Zatím žádné poznámky {project.notes}
</Typography> </Typography>
)} </Box>
{(notes.length > 0 || project.notes) && ( )}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => ( {/* Notes list */}
{notes.length === 0 && !project.notes && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 2 }}
>
Zatím žádné poznámky
</Typography>
)}
{(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => (
<Box
key={note.id}
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
position: "relative",
}}
>
<Box <Box
key={note.id}
sx={{ sx={{
p: 1.5, display: "flex",
bgcolor: "action.hover", justifyContent: "space-between",
borderRadius: 2, alignItems: "flex-start",
position: "relative", gap: 1,
}} }}
> >
<Box <Box sx={{ flex: 1 }}>
sx={{ <Box
display: "flex", sx={{
justifyContent: "space-between", display: "flex",
alignItems: "flex-start", alignItems: "center",
gap: 1, gap: 1,
}} mb: 0.5,
> }}
<Box sx={{ flex: 1 }}> >
<Box <Typography variant="body2" sx={{ fontWeight: 600 }}>
sx={{ {note.user_name}
display: "flex", </Typography>
alignItems: "center", <Typography variant="caption" color="text.secondary">
gap: 1, {formatNoteDate(note.created_at)}
mb: 0.5,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{note.user_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatNoteDate(note.created_at)}
</Typography>
</Box>
<Typography
variant="body2"
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
>
{note.content}
</Typography> </Typography>
</Box> </Box>
{isAdmin && ( <Typography
<IconButton variant="body2"
size="small" sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
color="error" >
onClick={() => handleDeleteNote(note.id)} {note.content}
title="Smazat poznámku" </Typography>
aria-label="Smazat poznámku"
disabled={deletingNoteId === note.id}
sx={{ flexShrink: 0 }}
>
{TrashIcon}
</IconButton>
)}
</Box> </Box>
{isAdmin && (
<IconButton
size="small"
color="error"
onClick={() => handleDeleteNote(note.id)}
title="Smazat poznámku"
aria-label="Smazat poznámku"
disabled={deletingNoteId === note.id}
sx={{ flexShrink: 0 }}
>
{TrashIcon}
</IconButton>
)}
</Box> </Box>
))} </Box>
</Box> ))}
)} </Box>
</Card> )}
</motion.div> </Card>
{/* Project File Manager */} {/* Project File Manager */}
<motion.div <Box sx={{ mb: 2 }}>
style={{ marginBottom: "1rem" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<ProjectFileManager <ProjectFileManager
projectId={project.id} projectId={project.id}
projectNumber={project.project_number} projectNumber={project.project_number}
hasPermission={hasPermission} hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder ?? false} hasNasFolder={project.has_nas_folder ?? false}
/> />
</motion.div> </Box>
{/* Links */} {/* Links */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Propojení
transition={{ duration: 0.25, delay: 0.15 }} </Typography>
> <Box
<Card sx={{ mb: 3 }}> sx={{
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> display: "grid",
Propojení gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
</Typography> gap: 2,
<Box }}
sx={{ >
display: "grid", <Box>
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, <Typography variant="caption" color="text.secondary">
gap: 2, Objednávka
}} </Typography>
> <Typography variant="body2">
<Box> {project.order_id ? (
<Typography variant="caption" color="text.secondary"> <Box
Objednávka component={RouterLink}
</Typography> to={`/orders/${project.order_id}`}
<Typography variant="body2"> sx={{
{project.order_id ? ( color: "primary.main",
<Box textDecoration: "none",
component={RouterLink} "&:hover": { textDecoration: "underline" },
to={`/orders/${project.order_id}`} }}
sx={{ >
color: "primary.main", {project.order_number}
textDecoration: "none", {project.order_status && (
"&:hover": { textDecoration: "underline" }, <Box
}} component="span"
> sx={{ color: "text.secondary", ml: 1 }}
{project.order_number} >
{project.order_status && ( (
<Box {STATUS_LABELS[project.order_status] ||
component="span" project.order_status}
sx={{ color: "text.secondary", ml: 1 }} )
> </Box>
( )}
{STATUS_LABELS[project.order_status] || </Box>
project.order_status} ) : (
) "—"
</Box> )}
)} </Typography>
</Box>
) : (
"—"
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? (
<Box
component={RouterLink}
to={`/offers/${project.quotation_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.quotation_number}
</Box>
) : (
"—"
)}
</Typography>
</Box>
</Box> </Box>
</Card> <Box>
</motion.div> <Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? (
<Box
component={RouterLink}
to={`/offers/${project.quotation_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.quotation_number}
</Box>
) : (
"—"
)}
</Typography>
</Box>
</Box>
</Card>
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm} isOpen={deleteConfirm}
@@ -687,6 +660,6 @@ export default function ProjectDetail() {
/> />
)} )}
</ConfirmDialog> </ConfirmDialog>
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom"; import { Link as RouterLink, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -32,6 +31,7 @@ import {
StatusChip, StatusChip,
CheckboxField, CheckboxField,
LoadingState, LoadingState,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -346,30 +346,24 @@ export default function Projects() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
> >
<Box <Typography variant="h4">Projekty</Typography>
sx={{ {hasPermission("projects.create") && (
display: "flex", <Button startIcon={PlusIcon} onClick={openCreate}>
alignItems: "center", Přidat projekt
justifyContent: "space-between", </Button>
mb: 3, )}
flexWrap: "wrap", </Box>
gap: 2,
}}
>
<Typography variant="h4">Projekty</Typography>
{hasPermission("projects.create") && (
<Button startIcon={PlusIcon} onClick={openCreate}>
Přidat projekt
</Button>
)}
</Box>
</motion.div>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<TextField <TextField
@@ -383,38 +377,32 @@ export default function Projects() {
/> />
</Box> </Box>
<motion.div <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
initial={{ opacity: 0, y: 12 }} <DataTable<Project>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.06 }} rows={projects}
> rowKey={(p) => p.id}
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> sortBy={sort}
<DataTable<Project> sortDir={order}
columns={columns} onSort={handleSort}
rows={projects} empty={
rowKey={(p) => p.id} <Box sx={{ textAlign: "center", py: 6 }}>
sortBy={sort} <Typography color="text.secondary" gutterBottom>
sortDir={order} Zatím nejsou žádné projekty.
onSort={handleSort} </Typography>
empty={ <Typography variant="body2" color="text.secondary">
<Box sx={{ textAlign: "center", py: 6 }}> Projekty vznikají z objednávek nebo je můžete vytvořit ručně
<Typography color="text.secondary" gutterBottom> tlačítkem Přidat projekt".
Zatím nejsou žádné projekty. </Typography>
</Typography> </Box>
<Typography variant="body2" color="text.secondary"> }
Projekty vznikají z objednávek nebo je můžete vytvořit ručně />
tlačítkem Přidat projekt". <Pagination
</Typography> page={page}
</Box> pageCount={pagination?.total_pages ?? 1}
} onChange={setPage}
/> />
<Pagination </Card>
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</motion.div>
<ConfirmDialog <ConfirmDialog
isOpen={!!deleteTarget} isOpen={!!deleteTarget}
@@ -538,6 +526,6 @@ export default function Projects() {
/> />
</Field> </Field>
</Modal> </Modal>
</Box> </PageEnter>
); );
} }

View File

@@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Navigate, useSearchParams } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -27,6 +26,7 @@ import {
Tabs, Tabs,
TabPanel, TabPanel,
LoadingState, LoadingState,
PageEnter,
type DataColumn, type DataColumn,
type TabDef, type TabDef,
} from "../ui"; } from "../ui";
@@ -780,23 +780,17 @@ export default function Settings() {
}; };
return ( return (
<Box> <PageEnter>
<motion.div <Box sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="h4">Nastavení</Typography>
animate={{ opacity: 1, y: 0 }} <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
transition={{ duration: 0.25 }} {activeTab === "system"
> ? "Systémová nastavení"
<Box sx={{ mb: 3 }}> : activeTab === "firma"
<Typography variant="h4">Nastavení</Typography> ? "Informace o firmě"
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> : "Role"}
{activeTab === "system" </Typography>
? "Systémová nastavení" </Box>
: activeTab === "firma"
? "Informace o firmě"
: "Role"}
</Typography>
</Box>
</motion.div>
{tabDefs.length > 0 && ( {tabDefs.length > 0 && (
<Tabs <Tabs
@@ -808,43 +802,32 @@ export default function Settings() {
{/* Roles tab */} {/* Roles tab */}
<TabPanel value="roles" current={activeTab}> <TabPanel value="roles" current={activeTab}>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25, delay: 0.06 }} justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
> >
<Box <Typography variant="h6">Role</Typography>
sx={{ <Button startIcon={PlusIcon} size="small" onClick={openCreateModal}>
display: "flex", Přidat roli
justifyContent: "space-between", </Button>
alignItems: "center", </Box>
mb: 2, <Card>
}} <DataTable<Role>
> columns={roleColumns}
<Typography variant="h6">Role</Typography> rows={roles}
<Button startIcon={PlusIcon} size="small" onClick={openCreateModal}> rowKey={(role) => role.id}
Přidat roli />
</Button> </Card>
</Box>
<Card>
<DataTable<Role>
columns={roleColumns}
rows={roles}
rowKey={(role) => role.id}
/>
</Card>
</motion.div>
</TabPanel> </TabPanel>
{/* System settings tab */} {/* System settings tab */}
<TabPanel value="system" current={activeTab}> <TabPanel value="system" current={activeTab}>
{canManageSystem && ( {canManageSystem && (
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Zabezpečení Zabezpečení
@@ -908,16 +891,11 @@ export default function Settings() {
)} )}
</Box> </Box>
</Card> </Card>
</motion.div> </Box>
)} )}
{canManageSystem && ( {canManageSystem && (
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Přihlašování Přihlašování
@@ -968,7 +946,7 @@ export default function Settings() {
</Button> </Button>
</Box> </Box>
</Card> </Card>
</motion.div> </Box>
)} )}
{sysSettingsLoading && !sysFormInitialized ? ( {sysSettingsLoading && !sysFormInitialized ? (
@@ -976,12 +954,7 @@ export default function Settings() {
) : ( ) : (
<> <>
{/* Section 1: Docházka */} {/* Section 1: Docházka */}
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Docházka Docházka
@@ -1051,15 +1024,10 @@ export default function Settings() {
</Field> </Field>
</Box> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Section 2: Emailové notifikace */} {/* Section 2: Emailové notifikace */}
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Emailové notifikace Emailové notifikace
@@ -1124,15 +1092,10 @@ export default function Settings() {
</Field> </Field>
</Box> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Section 4: Omezení požadavků */} {/* Section 4: Omezení požadavků */}
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.24 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Omezení požadavků Omezení požadavků
@@ -1154,39 +1117,26 @@ export default function Settings() {
/> />
</Field> </Field>
</Card> </Card>
</motion.div> </Box>
{/* Section 5: Informace o aplikaci */} {/* Section 5: Informace o aplikaci */}
<motion.div <Box sx={{ mb: "1.5rem" }}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.36 }}
style={{ marginBottom: "1.5rem" }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Informace o aplikaci Informace o aplikaci
</Typography> </Typography>
{renderSystemInfo()} {renderSystemInfo()}
</Card> </Card>
</motion.div> </Box>
{/* Save button */} {/* Save button */}
<motion.div <Button
initial={{ opacity: 0, y: 12 }} fullWidth
animate={{ opacity: 1, y: 0 }} onClick={handleSaveSystemSettings}
transition={{ duration: 0.25, delay: 0.42 }} disabled={saveSystemSettingsMutation.isPending}
> >
<Button {saveSystemSettingsMutation.isPending ? "Ukládání..." : "Uložit"}
fullWidth </Button>
onClick={handleSaveSystemSettings}
disabled={saveSystemSettingsMutation.isPending}
>
{saveSystemSettingsMutation.isPending
? "Ukládání..."
: "Uložit"}
</Button>
</motion.div>
</> </>
)} )}
</TabPanel> </TabPanel>
@@ -1399,6 +1349,6 @@ export default function Settings() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteRoleMutation.isPending} loading={deleteRoleMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -30,6 +29,7 @@ import {
StatusChip, StatusChip,
LoadingState, LoadingState,
StatCard, StatCard,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -418,124 +418,106 @@ export default function Trips() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Box>
<Typography variant="h4">Kniha jízd</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{new Date().toLocaleDateString("cs-CZ", {
month: "long",
year: "numeric",
})}
</Typography>
</Box>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat jízdu
</Button>
</Box>
{/* Stats Cards */}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
lg: "repeat(4, 1fr)",
},
gap: 2,
}}
>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
<StatCard
label="Soukromé"
value={`${formatKm(totals.private)} km`}
icon={PrivateIcon}
color="warning"
/>
</Box>
{/* Recent Trips */}
<Card sx={{ mt: 3 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
flexWrap: "wrap", mb: 2,
gap: 2,
mb: 3,
}} }}
> >
<Box> <Typography variant="h6">Poslední jízdy</Typography>
<Typography variant="h4">Kniha jízd</Typography> <Button
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> component={RouterLink}
{new Date().toLocaleDateString("cs-CZ", { to="/trips/history"
month: "long", variant="outlined"
year: "numeric", color="inherit"
})} size="small"
</Typography> >
</Box> Zobrazit historii
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat jízdu
</Button> </Button>
</Box> </Box>
</motion.div> <DataTable<BackendTrip>
columns={columns}
{/* Stats Cards */} rows={recentTrips}
<motion.div rowKey={(trip) => trip.id}
initial={{ opacity: 0, y: 12 }} empty={
animate={{ opacity: 1, y: 0 }} <Box sx={{ textAlign: "center", py: 6 }}>
transition={{ duration: 0.25, delay: 0.06 }} <Typography color="text.secondary" gutterBottom>
> Zatím nemáte žádné záznamy jízd.
<Box </Typography>
sx={{ <Button startIcon={PlusIcon} onClick={openCreateModal}>
display: "grid", Přidat první jízdu
gridTemplateColumns: { </Button>
xs: "1fr", </Box>
sm: "1fr 1fr", }
lg: "repeat(4, 1fr)", />
}, </Card>
gap: 2,
}}
>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
<StatCard
label="Soukromé"
value={`${formatKm(totals.private)} km`}
icon={PrivateIcon}
color="warning"
/>
</Box>
</motion.div>
{/* Recent Trips */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Card sx={{ mt: 3 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Poslední jízdy</Typography>
<Button
component={RouterLink}
to="/trips/history"
variant="outlined"
color="inherit"
size="small"
>
Zobrazit historii
</Button>
</Box>
<DataTable<BackendTrip>
columns={columns}
rows={recentTrips}
rowKey={(trip) => trip.id}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nemáte žádné záznamy jízd.
</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat první jízdu
</Button>
</Box>
}
/>
</Card>
</motion.div>
{/* Add/Edit Modal */} {/* Add/Edit Modal */}
<Modal <Modal
@@ -696,6 +678,6 @@ export default function Trips() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -33,6 +32,7 @@ import {
LoadingState, LoadingState,
EmptyState, EmptyState,
StatCard, StatCard,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -529,171 +529,147 @@ export default function TripsAdmin() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Box <Typography variant="h4">Správa knihy jízd</Typography>
sx={{ <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
display: "flex", {trips.length > 0 && (
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && (
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
title="Tisk knihy jízd"
>
Tisk
</Button>
)}
<Button <Button
component={RouterLink}
to="/vehicles"
variant="outlined" variant="outlined"
color="inherit" color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
title="Tisk knihy jízd"
> >
Vozidla Tisk
</Button> </Button>
</Box> )}
<Button
component={RouterLink}
to="/vehicles"
variant="outlined"
color="inherit"
>
Vozidla
</Button>
</Box> </Box>
</motion.div> </Box>
{/* Filters */} {/* Filters */}
<motion.div <FilterBar>
initial={{ opacity: 0, y: 12 }} <Box sx={{ flex: "0 0 160px" }}>
animate={{ opacity: 1, y: 0 }} <Field label="Měsíc">
transition={{ duration: 0.25, delay: 0.06 }} <Select
> value={filterMonth}
<FilterBar> onChange={setFilterMonth}
<Box sx={{ flex: "0 0 160px" }}> options={Array.from({ length: 12 }, (_, i) => ({
<Field label="Měsíc"> value: String(i + 1),
<Select label: new Date(2000, i).toLocaleString("cs-CZ", {
value={filterMonth} month: "long",
onChange={setFilterMonth} }),
options={Array.from({ length: 12 }, (_, i) => ({ }))}
value: String(i + 1), />
label: new Date(2000, i).toLocaleString("cs-CZ", { </Field>
month: "long", </Box>
}), <Box sx={{ flex: "0 0 120px" }}>
}))} <Field label="Rok">
/> <Select
</Field> value={filterYear}
</Box> onChange={setFilterYear}
<Box sx={{ flex: "0 0 120px" }}> options={Array.from({ length: 5 }, (_, i) => {
<Field label="Rok"> const y = new Date().getFullYear() - 2 + i;
<Select return { value: String(y), label: String(y) };
value={filterYear} })}
onChange={setFilterYear} />
options={Array.from({ length: 5 }, (_, i) => { </Field>
const y = new Date().getFullYear() - 2 + i; </Box>
return { value: String(y), label: String(y) }; <Box sx={{ flex: "1 1 200px" }}>
})} <Field label="Vozidlo">
/> <Select
</Field> value={filterVehicleId}
</Box> onChange={setFilterVehicleId}
<Box sx={{ flex: "1 1 200px" }}> options={[
<Field label="Vozidlo"> { value: "", label: "Všechna vozidla" },
<Select ...vehicles.map((v) => ({
value={filterVehicleId} value: String(v.id),
onChange={setFilterVehicleId} label: `${v.spz} - ${v.name}`,
options={[ })),
{ value: "", label: "Všechna vozidla" }, ]}
...vehicles.map((v) => ({ />
value: String(v.id), </Field>
label: `${v.spz} - ${v.name}`, </Box>
})), <Box sx={{ flex: "1 1 200px" }}>
]} <Field label="Řidič">
/> <Select
</Field> value={filterUserId}
</Box> onChange={setFilterUserId}
<Box sx={{ flex: "1 1 200px" }}> options={[
<Field label=idič"> { value: "", label: "Všichni řidiči" },
<Select ...tripUsers.map((u) => ({
value={filterUserId} value: String(u.id),
onChange={setFilterUserId} label: u.name,
options={[ })),
{ value: "", label: "Všichni řidiči" }, ]}
...tripUsers.map((u) => ({ />
value: String(u.id), </Field>
label: u.name, </Box>
})), </FilterBar>
]}
/>
</Field>
</Box>
</FilterBar>
</motion.div>
{/* Stats Cards */} {/* Stats Cards */}
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "grid",
transition={{ duration: 0.25, delay: 0.08 }} gridTemplateColumns: {
xs: "1fr",
sm: "repeat(3, 1fr)",
},
gap: 2,
}}
> >
<Box <StatCard
sx={{ label="Počet jízd"
display: "grid", value={totals.count}
gridTemplateColumns: { icon={TripsIcon}
xs: "1fr", color="info"
sm: "repeat(3, 1fr)", />
}, <StatCard
gap: 2, label="Celkem naježděno"
}} value={`${formatKm(totals.total)} km`}
> icon={TotalIcon}
<StatCard color="default"
label="Počet jízd" />
value={totals.count} <StatCard
icon={TripsIcon} label="Služební km"
color="info" value={`${formatKm(totals.business)} km`}
/> icon={BusinessIcon}
<StatCard color="success"
label="Celkem naježděno" />
value={`${formatKm(totals.total)} km`} </Box>
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební km"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
</Box>
</motion.div>
{/* Trips Table */} {/* Trips Table */}
<motion.div <Card sx={{ mt: 3 }}>
initial={{ opacity: 0, y: 12 }} {isPending ? (
animate={{ opacity: 1, y: 0 }} <LoadingState />
transition={{ duration: 0.25, delay: 0.12 }} ) : (
> <DataTable<Trip>
<Card sx={{ mt: 3 }}> columns={columns}
{isPending ? ( rows={trips}
<LoadingState /> rowKey={(trip) => trip.id}
) : ( empty={
<DataTable<Trip> <EmptyState title="Žádné záznamy jízd pro vybrané období." />
columns={columns} }
rows={trips} />
rowKey={(trip) => trip.id} )}
empty={ </Card>
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
)}
</Card>
</motion.div>
{/* Edit Modal */} {/* Edit Modal */}
<Modal <Modal
@@ -962,6 +938,6 @@ export default function TripsAdmin() {
</table> </table>
</div> </div>
)} )}
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -15,6 +14,7 @@ import {
MonthField, MonthField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -216,104 +216,80 @@ export default function TripsHistory() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
</motion.div>
{/* Filters */} {/* Filters */}
<motion.div <FilterBar>
initial={{ opacity: 0, y: 12 }} <Box sx={{ flex: "1 1 200px" }}>
animate={{ opacity: 1, y: 0 }} <Field label="Měsíc">
transition={{ duration: 0.25, delay: 0.06 }} <MonthField value={month} onChange={(val) => setMonth(val)} />
> </Field>
<FilterBar>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Měsíc">
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Vozidlo">
<Select
value={vehicleId}
onChange={(value) => setVehicleId(value)}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Field>
</Box>
</FilterBar>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
lg: "repeat(3, 1fr)",
},
gap: 2,
mt: 3,
}}
>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební km"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
</Box> </Box>
</motion.div> <Box sx={{ flex: "1 1 200px" }}>
<Field label="Vozidlo">
<Select
value={vehicleId}
onChange={(value) => setVehicleId(value)}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Field>
</Box>
</FilterBar>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
lg: "repeat(3, 1fr)",
},
gap: 2,
mt: 3,
}}
>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební km"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
</Box>
{/* Trips Table */} {/* Trips Table */}
<motion.div <Card sx={{ mt: 3 }}>
initial={{ opacity: 0, y: 12 }} {isPending ? (
animate={{ opacity: 1, y: 0 }} <LoadingState />
transition={{ duration: 0.25, delay: 0.12 }} ) : (
> <DataTable<Trip>
<Card sx={{ mt: 3 }}> columns={columns}
{isPending ? ( rows={trips}
<LoadingState /> rowKey={(trip) => trip.id}
) : ( empty={
<DataTable<Trip> <EmptyState title="Žádné záznamy jízd pro vybrané období." />
columns={columns} }
rows={trips} />
rowKey={(trip) => trip.id} )}
empty={ </Card>
<EmptyState title="Žádné záznamy jízd pro vybrané období." /> </PageEnter>
}
/>
)}
</Card>
</motion.div>
</Box>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -26,6 +25,7 @@ import {
Select, Select,
StatusChip, StatusChip,
LoadingState, LoadingState,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -324,53 +324,41 @@ export default function Users() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
> >
<Box <Typography variant="h4">Správa uživatelů</Typography>
sx={{ <Button startIcon={PlusIcon} onClick={openCreateModal}>
display: "flex", Přidat uživatele
alignItems: "center", </Button>
justifyContent: "space-between", </Box>
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h4">Správa uživatelů</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat uživatele
</Button>
</Box>
</motion.div>
<motion.div <Card>
initial={{ opacity: 0, y: 12 }} <DataTable<User>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.06 }} rows={users}
> rowKey={(u) => u.id}
<Card> rowInactive={(u) => !u.is_active}
<DataTable<User> empty={
columns={columns} <Box sx={{ textAlign: "center", py: 6 }}>
rows={users} <Typography color="text.secondary" gutterBottom>
rowKey={(u) => u.id} Zatím nejsou žádní uživatelé.
rowInactive={(u) => !u.is_active} </Typography>
empty={ <Button startIcon={PlusIcon} onClick={openCreateModal}>
<Box sx={{ textAlign: "center", py: 6 }}> Přidat prvního uživatele
<Typography color="text.secondary" gutterBottom> </Button>
Zatím nejsou žádní uživatelé. </Box>
</Typography> }
<Button startIcon={PlusIcon} onClick={openCreateModal}> />
Přidat prvního uživatele </Card>
</Button>
</Box>
}
/>
</Card>
</motion.div>
<Modal <Modal
isOpen={showModal} isOpen={showModal}
@@ -478,6 +466,6 @@ export default function Users() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteUser.isPending} loading={deleteUser.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip"; import Chip from "@mui/material/Chip";
@@ -21,6 +20,7 @@ import {
TextField, TextField,
SwitchField, SwitchField,
LoadingState, LoadingState,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -299,53 +299,41 @@ export default function Vehicles() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
> >
<Box <Typography variant="h4">Správa vozidel</Typography>
sx={{ <Button startIcon={PlusIcon} onClick={openCreateModal}>
display: "flex", Přidat vozidlo
alignItems: "center", </Button>
justifyContent: "space-between", </Box>
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h4">Správa vozidel</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat vozidlo
</Button>
</Box>
</motion.div>
<motion.div <Card>
initial={{ opacity: 0, y: 12 }} <DataTable<Vehicle>
animate={{ opacity: 1, y: 0 }} columns={columns}
transition={{ duration: 0.25, delay: 0.06 }} rows={vehicles}
> rowKey={(v) => v.id}
<Card> rowInactive={(v) => !v.is_active}
<DataTable<Vehicle> empty={
columns={columns} <Box sx={{ textAlign: "center", py: 6 }}>
rows={vehicles} <Typography color="text.secondary" gutterBottom>
rowKey={(v) => v.id} Zatím nejsou žádná vozidla.
rowInactive={(v) => !v.is_active} </Typography>
empty={ <Button startIcon={PlusIcon} onClick={openCreateModal}>
<Box sx={{ textAlign: "center", py: 6 }}> Přidat první vozidlo
<Typography color="text.secondary" gutterBottom> </Button>
Zatím nejsou žádná vozidla. </Box>
</Typography> }
<Button startIcon={PlusIcon} onClick={openCreateModal}> />
Přidat první vozidlo </Card>
</Button>
</Box>
}
/>
</Card>
</motion.div>
<Modal <Modal
isOpen={showModal} isOpen={showModal}
@@ -438,6 +426,6 @@ export default function Vehicles() {
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -22,6 +21,7 @@ import {
EmptyState, EmptyState,
LoadingState, LoadingState,
PageHeader, PageHeader,
PageEnter,
type DataColumn, type DataColumn,
type StatCardColor, type StatCardColor,
} from "../ui"; } from "../ui";
@@ -193,74 +193,64 @@ export default function Warehouse() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Sklad"
animate={{ opacity: 1, y: 0 }} subtitle="Přehled skladových zásob"
transition={{ duration: 0.25 }} actions={
> <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<PageHeader {hasPermission("warehouse.operate") && (
title="Sklad" <>
subtitle="Přehled skladových zásob"
actions={
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasPermission("warehouse.operate") && (
<>
<Button
component={RouterLink}
to="/warehouse/receipts/new"
startIcon={ReceiptIcon}
>
Nový příjem
</Button>
<Button
component={RouterLink}
to="/warehouse/issues/new"
variant="outlined"
color="inherit"
startIcon={IssueIcon}
>
Nový výdej
</Button>
</>
)}
{hasPermission("warehouse.manage") && (
<Button <Button
component={RouterLink} component={RouterLink}
to="/warehouse/categories" to="/warehouse/receipts/new"
startIcon={ReceiptIcon}
>
Nový příjem
</Button>
<Button
component={RouterLink}
to="/warehouse/issues/new"
variant="outlined" variant="outlined"
color="inherit" color="inherit"
startIcon={IssueIcon}
> >
Kategorie Nový výdej
</Button> </Button>
)} </>
)}
{hasPermission("warehouse.manage") && (
<Button <Button
component={RouterLink} component={RouterLink}
to="/warehouse/items" to="/warehouse/categories"
variant="outlined" variant="outlined"
color="inherit" color="inherit"
> >
Položky Kategorie
</Button> </Button>
<Button )}
component={RouterLink} <Button
to="/warehouse/reports" component={RouterLink}
variant="outlined" to="/warehouse/items"
color="inherit" variant="outlined"
> color="inherit"
Reporty >
</Button> Položky
</Box> </Button>
} <Button
/> component={RouterLink}
</motion.div> to="/warehouse/reports"
variant="outlined"
color="inherit"
>
Reporty
</Button>
</Box>
}
/>
{/* KPI cards */} {/* KPI cards */}
<Box <Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { gridTemplateColumns: {
@@ -296,13 +286,7 @@ export default function Warehouse() {
{/* Below minimum alert */} {/* Below minimum alert */}
{belowMin.length > 0 && ( {belowMin.length > 0 && (
<Box <Box sx={{ mb: 3 }}>
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
sx={{ mb: 3 }}
>
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}> <Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<Box <Box
sx={{ sx={{
@@ -342,12 +326,7 @@ export default function Warehouse() {
)} )}
{/* Recent movements */} {/* Recent movements */}
<Box <Box>
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.16 }}
>
<Card> <Card>
<Box <Box
sx={{ sx={{
@@ -381,6 +360,6 @@ export default function Warehouse() {
)} )}
</Card> </Card>
</Box> </Box>
</Box> </PageEnter>
); );
} }

View File

@@ -19,6 +19,7 @@ import {
Field, Field,
TextField, TextField,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
@@ -238,7 +239,7 @@ export default function WarehouseCategories() {
]; ];
return ( return (
<Box> <PageEnter>
<PageHeader <PageHeader
title="Kategorie skladu" title="Kategorie skladu"
subtitle={subtitle} subtitle={subtitle}
@@ -327,6 +328,6 @@ export default function WarehouseCategories() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -19,6 +18,7 @@ import {
Select, Select,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -127,25 +127,19 @@ export default function WarehouseInventory() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Inventury"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> <Button
<PageHeader startIcon={PlusIcon}
title="Inventury" onClick={() => navigate("/warehouse/inventory/new")}
subtitle={subtitle} >
actions={ Nová inventura
<Button </Button>
startIcon={PlusIcon} }
onClick={() => navigate("/warehouse/inventory/new")} />
>
Nová inventura
</Button>
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "0 0 200px" }}> <Box sx={{ flex: "0 0 200px" }}>
@@ -199,6 +193,6 @@ export default function WarehouseInventory() {
onChange={setPage} onChange={setPage}
/> />
</Card> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, Link as RouterLink } from "react-router-dom"; import { useParams, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -23,6 +22,7 @@ import {
EmptyState, EmptyState,
LoadingState, LoadingState,
PageHeader, PageHeader,
PageEnter,
ConfirmDialog, ConfirmDialog,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -187,117 +187,99 @@ export default function WarehouseInventoryDetail() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title={s.session_number || "Inventura"}
animate={{ opacity: 1, y: 0 }} actions={
transition={{ duration: 0.25 }} <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
> <Button
<PageHeader component={RouterLink}
title={s.session_number || "Inventura"} to="/warehouse/inventory"
actions={ variant="outlined"
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[s.status] ?? s.status}
color={STATUS_COLOR[s.status] ?? "default"}
/>
{s.status === "DRAFT" && (
<Button <Button
component={RouterLink} onClick={() => setShowConfirmModal(true)}
to="/warehouse/inventory" disabled={confirming}
variant="outlined"
color="inherit"
startIcon={BackIcon}
> >
Zpět {confirming ? "Potvrzování..." : "Potvrdit inventuru"}
</Button> </Button>
<StatusChip
label={STATUS_LABEL[s.status] ?? s.status}
color={STATUS_COLOR[s.status] ?? "default"}
/>
{s.status === "DRAFT" && (
<Button
onClick={() => setShowConfirmModal(true)}
disabled={confirming}
>
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
</Button>
)}
</Box>
}
/>
</motion.div>
{/* Header info */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo inventury
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{s.session_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(s.created_at)}
</Typography>
</Box>
{s.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{s.notes}
</Typography>
</Box>
)} )}
</Box> </Box>
</Card> }
</motion.div> />
{/* Header info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo inventury
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{s.session_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(s.created_at)}
</Typography>
</Box>
{s.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{s.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */} {/* Lines table */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Položky
transition={{ duration: 0.25, delay: 0.08 }} </Typography>
> <DataTable<WarehouseInventoryItem>
<Card sx={{ mb: 3 }}> columns={itemColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={items}
Položky rowKey={(row) => row.id}
</Typography> empty={<EmptyState title="Žádné řádky" />}
<DataTable<WarehouseInventoryItem> />
columns={itemColumns} </Card>
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
</motion.div>
{/* Confirm modal */} {/* Confirm modal */}
<ConfirmDialog <ConfirmDialog
@@ -309,6 +291,6 @@ export default function WarehouseInventoryDetail() {
confirmText="Potvrdit inventuru" confirmText="Potvrdit inventuru"
loading={confirming} loading={confirming}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState, useId, useRef } from "react"; import { useState, useId, useRef } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -14,7 +13,7 @@ import {
type WarehouseLocation, type WarehouseLocation,
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { Button, Card, TextField, Select, Field } from "../ui"; import { Button, Card, TextField, Select, Field, PageEnter } from "../ui";
interface InventoryItem { interface InventoryItem {
key: string; key: string;
@@ -165,139 +164,121 @@ export default function WarehouseInventoryForm() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Box <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
sx={{ <IconButton
display: "flex", component={RouterLink}
alignItems: "flex-start", to="/warehouse/inventory"
justifyContent: "space-between", title="Zpět na seznam"
flexWrap: "wrap", aria-label="Zpět na seznam"
gap: 2, >
mb: 3, {BackIcon}
}} </IconButton>
> <Typography variant="h4">Nová inventura</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/inventory"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
{BackIcon}
</IconButton>
<Typography variant="h4">Nová inventura</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? "Ukládání..." : "Vytvořit inventuru"}
</Button>
</Box>
</Box> </Box>
</motion.div> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? "Ukládání..." : "Vytvořit inventuru"}
</Button>
</Box>
</Box>
{/* Notes */} {/* Notes */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Základní údaje
transition={{ duration: 0.25, delay: 0.06 }} </Typography>
> <Field label="Poznámky">
<Card sx={{ mb: 3 }}> <TextField
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> multiline
Základní údaje minRows={3}
</Typography> value={notes}
<Field label="Poznámky"> onChange={(e) => setNotes(e.target.value)}
<TextField placeholder="Volitelné poznámky k inventuře"
multiline />
minRows={3} </Field>
value={notes} </Card>
onChange={(e) => setNotes(e.target.value)}
placeholder="Volitelné poznámky k inventuře"
/>
</Field>
</Card>
</motion.div>
{/* Lines */} {/* Lines */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Položky
transition={{ duration: 0.25, delay: 0.08 }} </Typography>
> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<Card sx={{ mb: 3 }}> {items.map((item, index) => (
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Box
Položky key={item.key}
</Typography> sx={{
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> display: "grid",
{items.map((item, index) => ( gridTemplateColumns: {
<Box xs: "1fr",
key={item.key} md: "minmax(180px, 2fr) minmax(140px, 1.5fr) 120px auto",
sx={{ },
display: "grid", gap: 1,
gridTemplateColumns: { alignItems: "start",
xs: "1fr", }}
md: "minmax(180px, 2fr) minmax(140px, 1.5fr) 120px auto", >
}, <ItemPicker
gap: 1, value={item.item_id}
alignItems: "start", onChange={(id) => updateItem(index, "item_id", id)}
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
type="number"
value={item.actual_qty || ""}
onChange={(e) =>
updateItem(index, "actual_qty", Number(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Skutečné množství, řádek ${index + 1}`,
}} }}
placeholder="Skutečné množství"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
> >
<ItemPicker {RemoveIcon}
value={item.item_id} </IconButton>
onChange={(id) => updateItem(index, "item_id", id)} </Box>
/> ))}
<Select </Box>
value={ <Button
item.location_id === null ? "" : String(item.location_id) variant="outlined"
} color="inherit"
onChange={(val) => startIcon={PlusIcon}
updateItem(index, "location_id", val ? Number(val) : null) onClick={addItem}
} sx={{ mt: 2 }}
options={locationOptions} >
/> Přidat položku
<TextField </Button>
type="number" </Card>
value={item.actual_qty || ""} </PageEnter>
onChange={(e) =>
updateItem(index, "actual_qty", Number(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Skutečné množství, řádek ${index + 1}`,
}}
placeholder="Skutečné množství"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
</motion.div>
</Box>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
@@ -24,6 +23,7 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
ConfirmDialog, ConfirmDialog,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -223,57 +223,42 @@ export default function WarehouseIssueDetail() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title={iss.issue_number || "Nová výdejka"}
animate={{ opacity: 1, y: 0 }} subtitle={
transition={{ duration: 0.25 }} iss.project
> ? `${iss.project.project_number} ${iss.project.name}`
<PageHeader : undefined
title={iss.issue_number || "Nová výdejka"} }
subtitle={ actions={
iss.project <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
? `${iss.project.project_number} ${iss.project.name}` <Button
: undefined component={RouterLink}
} to="/warehouse/issues"
actions={ variant="outlined"
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> color="inherit"
<Button startIcon={BackIcon}
component={RouterLink} >
to="/warehouse/issues" Zpět
variant="outlined" </Button>
color="inherit" <StatusChip
startIcon={BackIcon} label={STATUS_LABEL[iss.status] ?? iss.status}
> color={STATUS_COLOR[iss.status] ?? "default"}
Zpět />
</Button> {canOperate && iss.status === "DRAFT" && (
<StatusChip <>
label={STATUS_LABEL[iss.status] ?? iss.status} <Button
color={STATUS_COLOR[iss.status] ?? "default"} variant="outlined"
/> color="inherit"
{canOperate && iss.status === "DRAFT" && ( onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
<> >
<Button Upravit
variant="outlined" </Button>
color="inherit" <Button onClick={handleConfirm} disabled={confirming}>
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)} {confirming ? "Potvrzování..." : "Potvrdit"}
> </Button>
Upravit
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
{confirming ? "Potvrzování..." : "Potvrdit"}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
</>
)}
{canOperate && iss.status === "CONFIRMED" && (
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
@@ -281,126 +266,123 @@ export default function WarehouseIssueDetail() {
> >
Zrušit Zrušit
</Button> </Button>
)} </>
</Box> )}
} {canOperate && iss.status === "CONFIRMED" && (
/> <Button
</motion.div> variant="outlined"
color="error"
{/* Basic info */} onClick={() => setCancelConfirm(true)}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
> >
{iss.issue_number || "—"} Zrušit
</Typography> </Button>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
{iss.project ? (
<Typography
variant="body2"
component="a"
href={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
display: "block",
}}
>
{iss.project.project_number} {iss.project.name}
</Typography>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vydal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(iss.created_at)}
</Typography>
</Box>
{iss.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{iss.notes}
</Typography>
</Box>
)} )}
</Box> </Box>
</Card> }
</motion.div> />
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issue_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
{iss.project ? (
<Typography
variant="body2"
component="a"
href={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
display: "block",
}}
>
{iss.project.project_number} {iss.project.name}
</Typography>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vydal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(iss.created_at)}
</Typography>
</Box>
{iss.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{iss.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */} {/* Lines table */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Položky
transition={{ duration: 0.25, delay: 0.08 }} </Typography>
> <DataTable<WarehouseIssueItem>
<Card sx={{ mb: 3 }}> columns={itemColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={items}
Položky rowKey={(row) => row.id}
</Typography> empty={<EmptyState title="Žádné řádky" />}
<DataTable<WarehouseIssueItem> />
columns={itemColumns} </Card>
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
</motion.div>
{/* Cancel confirmation dialog */} {/* Cancel confirmation dialog */}
<ConfirmDialog <ConfirmDialog
@@ -413,6 +395,6 @@ export default function WarehouseIssueDetail() {
confirmVariant="danger" confirmVariant="danger"
loading={cancelling} loading={cancelling}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -6,7 +6,6 @@ import {
Link as RouterLink, Link as RouterLink,
} from "react-router-dom"; } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -23,7 +22,15 @@ import {
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects"; import { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { Button, Card, TextField, Select, Field, LoadingState } from "../ui"; import {
Button,
Card,
TextField,
Select,
Field,
LoadingState,
PageEnter,
} from "../ui";
interface IssueForm { interface IssueForm {
project_id: number | null; project_id: number | null;
@@ -422,199 +429,181 @@ export default function WarehouseIssueForm() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Box <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
sx={{ <IconButton
display: "flex", component={RouterLink}
alignItems: "flex-start", to="/warehouse/issues"
justifyContent: "space-between", title="Zpět na seznam"
flexWrap: "wrap", aria-label="Zpět na seznam"
gap: 2, >
mb: 3, {BackIcon}
}} </IconButton>
> <Typography variant="h4">
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> {isEdit ? "Upravit výdejku" : "Nová výdejka"}
<IconButton </Typography>
component={RouterLink}
to="/warehouse/issues"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveDraft}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</Button>
<Button onClick={handleSaveAndConfirm} disabled={saving}>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</Button>
</Box>
</Box> </Box>
</motion.div> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{/* Header fields */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Projekt" error={errors.project_id}>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val === "" ? null : Number(val),
}))
}
options={projectOptions}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Card>
</motion.div>
{/* Lines */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<BatchSelect
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
disabled
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
startIcon={PlusIcon} onClick={handleSaveDraft}
onClick={addItem} disabled={saving}
sx={{ mt: 2 }}
> >
Přidat položku {saving ? "Ukládání..." : "Uložit jako návrh"}
</Button> </Button>
</Card> <Button onClick={handleSaveAndConfirm} disabled={saving}>
</motion.div> {saving ? "Ukládání..." : "Uložit a potvrdit"}
</Box> </Button>
</Box>
</Box>
{/* Header fields */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Projekt" error={errors.project_id}>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val === "" ? null : Number(val),
}))
}
options={projectOptions}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Card>
{/* Lines */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<BatchSelect
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
disabled
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
</PageEnter>
); );
} }

View File

@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
@@ -24,6 +23,7 @@ import {
DateField, DateField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -166,27 +166,21 @@ export default function WarehouseIssues() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Výdejky"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> canOperate ? (
<PageHeader <Button
title="Výdejky" startIcon={PlusIcon}
subtitle={subtitle} onClick={() => navigate("/warehouse/issues/new")}
actions={ >
canOperate ? ( Nová výdejka
<Button </Button>
startIcon={PlusIcon} ) : undefined
onClick={() => navigate("/warehouse/issues/new")} }
> />
Nová výdejka
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 220px" }}> <Box sx={{ flex: "1 1 220px" }}>
@@ -292,6 +286,6 @@ export default function WarehouseIssues() {
onChange={setPage} onChange={setPage}
/> />
</Card> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
@@ -29,6 +28,7 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
ConfirmDialog, ConfirmDialog,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -356,145 +356,35 @@ export default function WarehouseItemDetail() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title={title}
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<PageHeader <Button
title={title} component={RouterLink}
subtitle={subtitle} to="/warehouse/items"
actions={ variant="outlined"
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> color="inherit"
<Button startIcon={BackIcon}
component={RouterLink} >
to="/warehouse/items" Zpět
variant="outlined" </Button>
color="inherit" {headerActions}
startIcon={BackIcon} </Box>
> }
Zpět />
</Button>
{headerActions}
</Box>
}
/>
</motion.div>
{/* Basic info */} {/* Basic info */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Základní údaje
transition={{ duration: 0.25, delay: 0.06 }} </Typography>
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
{editing || isNew ? ( {editing || isNew ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 0 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Číslo položky" error={errors.item_number}>
<TextField
value={form.item_number}
onChange={(e) => {
updateForm("item_number", e.target.value);
setErrors((prev) => ({ ...prev, item_number: "" }));
}}
placeholder="Např. SKL-001"
fullWidth
/>
</Field>
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
error={!!errors.name}
onChange={(e) => {
updateForm("name", e.target.value);
setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="Název položky"
fullWidth
/>
</Field>
</Box>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={form.description}
onChange={(e) => updateForm("description", e.target.value)}
placeholder="Volitelný popis položky"
fullWidth
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Kategorie">
<Select
value={form.category_id}
onChange={(v) => updateForm("category_id", v)}
>
<MenuItem value=""> Bez kategorie </MenuItem>
{categories.map((cat) => (
<MenuItem key={cat.id} value={String(cat.id)}>
{cat.name}
</MenuItem>
))}
</Select>
</Field>
<Field label="Jednotka" required error={errors.unit}>
<TextField
value={form.unit}
error={!!errors.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
setErrors((prev) => ({ ...prev, unit: "" }));
}}
placeholder="ks, m, kg..."
fullWidth
/>
</Field>
<Field
label="Minimální množství"
hint="Upozornění při poklesu pod tuto hranici"
>
<TextField
type="number"
inputProps={{ inputMode: "numeric", min: 0 }}
value={form.min_quantity}
onChange={(e) => updateForm("min_quantity", e.target.value)}
placeholder="0"
fullWidth
/>
</Field>
</Box>
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
placeholder="Volitelné poznámky"
fullWidth
/>
</Field>
</Box>
) : (
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
@@ -502,166 +392,246 @@ export default function WarehouseItemDetail() {
gap: 2, gap: 2,
}} }}
> >
{/* Číslo položky */} <Field label="Číslo položky" error={errors.item_number}>
<Box> <TextField
<Typography variant="caption" color="text.secondary"> value={form.item_number}
Číslo položky onChange={(e) => {
</Typography> updateForm("item_number", e.target.value);
<Typography setErrors((prev) => ({ ...prev, item_number: "" }));
variant="body2" }}
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }} placeholder="Např. SKL-001"
> fullWidth
{item?.item_number || "—"} />
</Typography> </Field>
</Box> <Field label="Název" required error={errors.name}>
{/* Název */} <TextField
<Box> value={form.name}
<Typography variant="caption" color="text.secondary"> error={!!errors.name}
Název onChange={(e) => {
</Typography> updateForm("name", e.target.value);
<Typography variant="body2" sx={{ fontWeight: 500 }}> setErrors((prev) => ({ ...prev, name: "" }));
{item?.name || "—"} }}
</Typography> placeholder="Název položky"
</Box> fullWidth
{/* Popis */} />
<Box sx={{ gridColumn: { sm: "1 / -1" } }}> </Field>
<Typography variant="caption" color="text.secondary">
Popis
</Typography>
<Typography variant="body2">
{item?.description || "—"}
</Typography>
</Box>
{/* Kategorie */}
<Box>
<Typography variant="caption" color="text.secondary">
Kategorie
</Typography>
<Typography variant="body2">
{item?.category?.name || "—"}
</Typography>
</Box>
{/* Jednotka */}
<Box>
<Typography variant="caption" color="text.secondary">
Jednotka
</Typography>
<Typography variant="body2">{item?.unit || "—"}</Typography>
</Box>
{/* Minimální množství */}
<Box>
<Typography variant="caption" color="text.secondary">
Minimální množství
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{item?.min_quantity != null ? item.min_quantity : "—"}
</Typography>
</Box>
{/* Poznámky */}
{item?.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{item.notes}
</Typography>
</Box>
)}
</Box> </Box>
)} <Field label="Popis">
</Card> <TextField
</motion.div> multiline
minRows={3}
{/* Stock info — only in view mode, for existing items */} value={form.description}
{id !== "new" && !editing && item && ( onChange={(e) => updateForm("description", e.target.value)}
<motion.div placeholder="Volitelný popis položky"
initial={{ opacity: 0, y: 12 }} fullWidth
animate={{ opacity: 1, y: 0 }} />
transition={{ duration: 0.25, delay: 0.08 }} </Field>
> <Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Kategorie">
<Select
value={form.category_id}
onChange={(v) => updateForm("category_id", v)}
>
<MenuItem value=""> Bez kategorie </MenuItem>
{categories.map((cat) => (
<MenuItem key={cat.id} value={String(cat.id)}>
{cat.name}
</MenuItem>
))}
</Select>
</Field>
<Field label="Jednotka" required error={errors.unit}>
<TextField
value={form.unit}
error={!!errors.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
setErrors((prev) => ({ ...prev, unit: "" }));
}}
placeholder="ks, m, kg..."
fullWidth
/>
</Field>
<Field
label="Minimální množství"
hint="Upozornění při poklesu pod tuto hranici"
>
<TextField
type="number"
inputProps={{ inputMode: "numeric", min: 0 }}
value={form.min_quantity}
onChange={(e) => updateForm("min_quantity", e.target.value)}
placeholder="0"
fullWidth
/>
</Field>
</Box>
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
placeholder="Volitelné poznámky"
fullWidth
/>
</Field>
</Box>
) : (
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
xs: "1fr 1fr",
md: "1fr 1fr 1fr 1fr",
},
gap: 2, gap: 2,
mb: 3,
}} }}
> >
<StatCard {/* Číslo položky */}
label="Celkové množství" <Box>
value={`${item.total_quantity ?? 0} ${item.unit}`} <Typography variant="caption" color="text.secondary">
color="info" Číslo položky
/> </Typography>
<StatCard <Typography
label="K dispozici" variant="body2"
value={`${item.available_quantity ?? 0} ${item.unit}`} sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
color="success" >
/> {item?.item_number || "—"}
<StatCard </Typography>
label="Hodnota zásob" </Box>
value={ {/* Název */}
item.stock_value != null <Box>
? formatCurrency(item.stock_value, "CZK") <Typography variant="caption" color="text.secondary">
: "0,00 Kč" Název
} </Typography>
color="warning" <Typography variant="body2" sx={{ fontWeight: 500 }}>
/> {item?.name || "—"}
<StatCard </Typography>
label="Pod minimem" </Box>
value={item.below_minimum ? "Ano" : "Ne"} {/* Popis */}
color={item.below_minimum ? "error" : "info"} <Box sx={{ gridColumn: { sm: "1 / -1" } }}>
/> <Typography variant="caption" color="text.secondary">
Popis
</Typography>
<Typography variant="body2">
{item?.description || "—"}
</Typography>
</Box>
{/* Kategorie */}
<Box>
<Typography variant="caption" color="text.secondary">
Kategorie
</Typography>
<Typography variant="body2">
{item?.category?.name || "—"}
</Typography>
</Box>
{/* Jednotka */}
<Box>
<Typography variant="caption" color="text.secondary">
Jednotka
</Typography>
<Typography variant="body2">{item?.unit || "—"}</Typography>
</Box>
{/* Minimální množství */}
<Box>
<Typography variant="caption" color="text.secondary">
Minimální množství
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{item?.min_quantity != null ? item.min_quantity : "—"}
</Typography>
</Box>
{/* Poznámky */}
{item?.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{item.notes}
</Typography>
</Box>
)}
</Box> </Box>
</motion.div> )}
</Card>
{/* Stock info — only in view mode, for existing items */}
{id !== "new" && !editing && item && (
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr 1fr",
md: "1fr 1fr 1fr 1fr",
},
gap: 2,
mb: 3,
}}
>
<StatCard
label="Celkové množství"
value={`${item.total_quantity ?? 0} ${item.unit}`}
color="info"
/>
<StatCard
label="K dispozici"
value={`${item.available_quantity ?? 0} ${item.unit}`}
color="success"
/>
<StatCard
label="Hodnota zásob"
value={
item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč"
}
color="warning"
/>
<StatCard
label="Pod minimem"
value={item.below_minimum ? "Ano" : "Ne"}
color={item.below_minimum ? "error" : "info"}
/>
</Box>
)} )}
{/* Batches table — only in view mode, for existing items */} {/* Batches table — only in view mode, for existing items */}
{id !== "new" && !editing && ( {id !== "new" && !editing && (
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Dávky (FIFO)
transition={{ duration: 0.25, delay: 0.1 }} </Typography>
> <DataTable<Batch>
<Card sx={{ mb: 3 }}> columns={batchColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={batches}
Dávky (FIFO) rowKey={(b) => b.id}
</Typography> empty={<EmptyState title="Zatím žádné dávky" />}
<DataTable<Batch> />
columns={batchColumns} </Card>
rows={batches}
rowKey={(b) => b.id}
empty={<EmptyState title="Zatím žádné dávky" />}
/>
</Card>
</motion.div>
)} )}
{/* Locations table — only in view mode, for existing items */} {/* Locations table — only in view mode, for existing items */}
{id !== "new" && !editing && ( {id !== "new" && !editing && (
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Umístění
transition={{ duration: 0.25, delay: 0.12 }} </Typography>
> <DataTable<ItemLocation>
<Card sx={{ mb: 3 }}> columns={locationColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={locations}
Umístění rowKey={(l) => l.id}
</Typography> empty={<EmptyState title="Zatím žádná umístění" />}
<DataTable<ItemLocation> />
columns={locationColumns} </Card>
rows={locations}
rowKey={(l) => l.id}
empty={<EmptyState title="Zatím žádná umístění" />}
/>
</Card>
</motion.div>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
@@ -675,6 +645,6 @@ export default function WarehouseItemDetail() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -24,6 +23,7 @@ import {
Select, Select,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -153,27 +153,21 @@ export default function WarehouseItems() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Skladové položky"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> canManage ? (
<PageHeader <Button
title="Skladové položky" startIcon={PlusIcon}
subtitle={subtitle} onClick={() => navigate("/warehouse/items/new")}
actions={ >
canManage ? ( Přidat položku
<Button </Button>
startIcon={PlusIcon} ) : undefined
onClick={() => navigate("/warehouse/items/new")} }
> />
Přidat položku
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 280px" }}> <Box sx={{ flex: "1 1 280px" }}>
@@ -243,6 +237,6 @@ export default function WarehouseItems() {
onChange={setPage} onChange={setPage}
/> />
</Card> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -20,6 +20,7 @@ import {
TextField, TextField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
EmptyState, EmptyState,
LoadingState, LoadingState,
type DataColumn, type DataColumn,
@@ -303,7 +304,7 @@ export default function WarehouseLocations() {
]; ];
return ( return (
<Box> <PageEnter>
<PageHeader <PageHeader
title="Umístění skladu" title="Umístění skladu"
subtitle={subtitle} subtitle={subtitle}
@@ -401,6 +402,6 @@ export default function WarehouseLocations() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -26,6 +25,7 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
ConfirmDialog, ConfirmDialog,
PageEnter,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -295,53 +295,38 @@ export default function WarehouseReceiptDetail() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title={r.receipt_number || "Nový doklad"}
animate={{ opacity: 1, y: 0 }} subtitle={r.supplier?.name}
transition={{ duration: 0.25 }} actions={
> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<PageHeader <Button
title={r.receipt_number || "Nový doklad"} component={RouterLink}
subtitle={r.supplier?.name} to="/warehouse/receipts"
actions={ variant="outlined"
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> color="inherit"
<Button startIcon={BackIcon}
component={RouterLink} >
to="/warehouse/receipts" Zpět
variant="outlined" </Button>
color="inherit" <StatusChip
startIcon={BackIcon} label={STATUS_LABEL[r.status] ?? r.status}
> color={STATUS_COLOR[r.status] ?? "default"}
Zpět />
</Button> {canOperate && r.status === "DRAFT" && (
<StatusChip <>
label={STATUS_LABEL[r.status] ?? r.status} <Button
color={STATUS_COLOR[r.status] ?? "default"} variant="outlined"
/> color="inherit"
{canOperate && r.status === "DRAFT" && ( onClick={() => navigate(`/warehouse/receipts/${r.id}/edit`)}
<> >
<Button Upravit
variant="outlined" </Button>
color="inherit" <Button onClick={handleConfirm} disabled={confirming}>
onClick={() => navigate(`/warehouse/receipts/${r.id}/edit`)} {confirming ? "Potvrzování..." : "Potvrdit"}
> </Button>
Upravit
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
{confirming ? "Potvrzování..." : "Potvrdit"}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
</>
)}
{canOperate && r.status === "CONFIRMED" && (
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
@@ -349,157 +334,148 @@ export default function WarehouseReceiptDetail() {
> >
Zrušit Zrušit
</Button> </Button>
)} </>
</Box> )}
} {canOperate && r.status === "CONFIRMED" && (
/> <Button
</motion.div> variant="outlined"
color="error"
{/* Basic info */} onClick={() => setCancelConfirm(true)}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
> >
{r.receipt_number || "—"} Zrušit
</Typography> </Button>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Dodavatel
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{r.supplier?.name || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dodacího listu
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{r.delivery_note_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Datum dodacího listu
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(r.delivery_note_date)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Přijal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{r.received_by_user
? `${r.received_by_user.first_name} ${r.received_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(r.created_at)}
</Typography>
</Box>
{r.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{r.notes}
</Typography>
</Box>
)} )}
</Box> </Box>
</Card> }
</motion.div> />
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{r.receipt_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Dodavatel
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{r.supplier?.name || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dodacího listu
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{r.delivery_note_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Datum dodacího listu
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(r.delivery_note_date)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Přijal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{r.received_by_user
? `${r.received_by_user.first_name} ${r.received_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(r.created_at)}
</Typography>
</Box>
{r.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{r.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */} {/* Lines table */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Položky
transition={{ duration: 0.25, delay: 0.08 }} </Typography>
> <DataTable<WarehouseReceiptItem>
<Card sx={{ mb: 3 }}> columns={itemColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={items}
Položky rowKey={(row) => row.id}
</Typography> empty={<EmptyState title="Žádné řádky" />}
<DataTable<WarehouseReceiptItem> />
columns={itemColumns} </Card>
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
</motion.div>
{/* Attachments */} {/* Attachments */}
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Přílohy
transition={{ duration: 0.25, delay: 0.1 }} </Typography>
> <DataTable<WarehouseReceiptAttachment>
<Card sx={{ mb: 3 }}> columns={attachmentColumns}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> rows={attachments}
Přílohy rowKey={(att) => att.id}
</Typography> empty={<EmptyState title="Žádné přílohy" />}
<DataTable<WarehouseReceiptAttachment> />
columns={attachmentColumns} </Card>
rows={attachments}
rowKey={(att) => att.id}
empty={<EmptyState title="Žádné přílohy" />}
/>
</Card>
</motion.div>
{/* Cancel confirmation dialog */} {/* Cancel confirmation dialog */}
<ConfirmDialog <ConfirmDialog
@@ -512,6 +488,6 @@ export default function WarehouseReceiptDetail() {
confirmVariant="danger" confirmVariant="danger"
loading={cancelling} loading={cancelling}
/> />
</Box> </PageEnter>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useRef, useCallback, useId } from "react"; import { useState, useEffect, useRef, useCallback, useId } from "react";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -28,6 +27,7 @@ import {
DateField, DateField,
Field, Field,
LoadingState, LoadingState,
PageEnter,
} from "../ui"; } from "../ui";
interface ReceiptForm { interface ReceiptForm {
@@ -410,294 +410,266 @@ export default function WarehouseReceiptForm() {
]; ];
return ( return (
<Box> <PageEnter>
{/* Header */} {/* Header */}
<motion.div <Box
initial={{ opacity: 0, y: 12 }} sx={{
animate={{ opacity: 1, y: 0 }} display: "flex",
transition={{ duration: 0.25 }} alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
> >
<Box <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
sx={{ <IconButton
display: "flex", component={RouterLink}
alignItems: "flex-start", to="/warehouse/receipts"
justifyContent: "space-between", title="Zpět na seznam"
flexWrap: "wrap", aria-label="Zpět na seznam"
gap: 2,
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/receipts"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit příjmový doklad" : "Nový příjmový doklad"}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveDraft}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</Button>
<Button onClick={handleSaveAndConfirm} disabled={saving}>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</Button>
</Box>
</Box>
</motion.div>
{/* Header fields */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Dodavatel">
<Select
value={form.supplier_id === null ? "" : String(form.supplier_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
supplier_id: val ? Number(val) : null,
}))
}
options={supplierOptions}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
> >
<Field label="Číslo dodacího listu"> {BackIcon}
<TextField </IconButton>
value={form.delivery_note_number} <Typography variant="h4">
onChange={(e) => {isEdit ? "Upravit příjmový doklad" : "Nový příjmový doklad"}
setForm((prev) => ({
...prev,
delivery_note_number: e.target.value,
}))
}
placeholder="Např. DL-2024-001"
/>
</Field>
<Field label="Datum dodacího listu">
<DateField
value={form.delivery_note_date}
onChange={(val) =>
setForm((prev) => ({
...prev,
delivery_note_date: val,
}))
}
/>
</Field>
</Box>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Card>
</motion.div>
{/* Lines */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography> </Typography>
{errors.items && ( </Box>
<Typography <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(180px, 2fr) 110px 110px minmax(140px, 1.2fr) minmax(140px, 1.5fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
startIcon={PlusIcon} onClick={handleSaveDraft}
onClick={addItem} disabled={saving}
sx={{ mt: 2 }}
> >
Přidat položku {saving ? "Ukládání..." : "Uložit jako návrh"}
</Button> </Button>
</Card> <Button onClick={handleSaveAndConfirm} disabled={saving}>
</motion.div> {saving ? "Ukládání..." : "Uložit a potvrdit"}
</Button>
</Box>
</Box>
{/* Header fields */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Dodavatel">
<Select
value={form.supplier_id === null ? "" : String(form.supplier_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
supplier_id: val ? Number(val) : null,
}))
}
options={supplierOptions}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Číslo dodacího listu">
<TextField
value={form.delivery_note_number}
onChange={(e) =>
setForm((prev) => ({
...prev,
delivery_note_number: e.target.value,
}))
}
placeholder="Např. DL-2024-001"
/>
</Field>
<Field label="Datum dodacího listu">
<DateField
value={form.delivery_note_date}
onChange={(val) =>
setForm((prev) => ({
...prev,
delivery_note_date: val,
}))
}
/>
</Field>
</Box>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Card>
{/* Lines */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(180px, 2fr) 110px 110px minmax(140px, 1.2fr) minmax(140px, 1.5fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(index, "unit_price", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
{/* File upload — only in edit mode for existing receipts */} {/* File upload — only in edit mode for existing receipts */}
{isEdit && ( {isEdit && (
<motion.div <Card sx={{ mb: 3 }}>
initial={{ opacity: 0, y: 12 }} <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
animate={{ opacity: 1, y: 0 }} Přílohy
transition={{ duration: 0.25, delay: 0.1 }} </Typography>
> <Box
<Card sx={{ mb: 3 }}> onDrop={handleDrop}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> onDragOver={handleDragOver}
Přílohy onClick={() => {
</Typography> const input = document.createElement("input");
<Box input.type = "file";
onDrop={handleDrop} input.multiple = true;
onDragOver={handleDragOver} input.onchange = () => {
onClick={() => { if (input.files && input.files.length > 0) {
const input = document.createElement("input"); handleFileUpload(input.files);
input.type = "file"; }
input.multiple = true; };
input.onchange = () => { input.click();
if (input.files && input.files.length > 0) { }}
handleFileUpload(input.files); sx={{
} display: "flex",
}; flexDirection: "column",
input.click(); alignItems: "center",
}} justifyContent: "center",
sx={{ gap: 1,
display: "flex", p: 4,
flexDirection: "column", border: "2px dashed",
alignItems: "center", borderColor: "divider",
justifyContent: "center", borderRadius: 1,
gap: 1, color: "text.secondary",
p: 4, cursor: "pointer",
border: "2px dashed", opacity: uploadingFiles ? 0.6 : 1,
borderColor: "divider", transition: "border-color .2s, background-color .2s",
borderRadius: 1, "&:hover": {
color: "text.secondary", borderColor: "primary.main",
cursor: "pointer", bgcolor: "action.hover",
opacity: uploadingFiles ? 0.6 : 1, },
transition: "border-color .2s, background-color .2s", }}
"&:hover": { >
borderColor: "primary.main", {uploadingFiles ? (
bgcolor: "action.hover", <>
}, <CircularProgress size={28} />
}} <Typography variant="body2">Nahrávání...</Typography>
> </>
{uploadingFiles ? ( ) : (
<> <>
<CircularProgress size={28} /> {UploadIcon}
<Typography variant="body2">Nahrávání...</Typography> <Typography variant="body2">
</> Přetáhněte soubory sem nebo klikněte pro výběr
) : ( </Typography>
<> <Typography variant="caption" color="text.secondary">
{UploadIcon} Dodací listy, faktury a další dokumenty
<Typography variant="body2"> </Typography>
Přetáhněte soubory sem nebo klikněte pro výběr </>
</Typography> )}
<Typography variant="caption" color="text.secondary"> </Box>
Dodací listy, faktury a další dokumenty </Card>
</Typography>
</>
)}
</Box>
</Card>
</motion.div>
)} )}
</Box> </PageEnter>
); );
} }

View File

@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
@@ -24,6 +23,7 @@ import {
DateField, DateField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -165,27 +165,21 @@ export default function WarehouseReceipts() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Příjmové doklady"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> canOperate ? (
<PageHeader <Button
title="Příjmové doklady" startIcon={PlusIcon}
subtitle={subtitle} onClick={() => navigate("/warehouse/receipts/new")}
actions={ >
canOperate ? ( Nový doklad
<Button </Button>
startIcon={PlusIcon} ) : undefined
onClick={() => navigate("/warehouse/receipts/new")} }
> />
Nový doklad
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 220px" }}> <Box sx={{ flex: "1 1 220px" }}>
@@ -291,6 +285,6 @@ export default function WarehouseReceipts() {
onChange={setPage} onChange={setPage}
/> />
</Card> </Card>
</Box> </PageEnter>
); );
} }

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { projectListOptions } from "../lib/queries/projects"; import { projectListOptions } from "../lib/queries/projects";
import { import {
@@ -21,6 +20,7 @@ import {
DateField, DateField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -89,26 +89,14 @@ export default function WarehouseReports() {
if (!hasPermission("warehouse.view")) return <Forbidden />; if (!hasPermission("warehouse.view")) return <Forbidden />;
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader title="Skladové reporty" />
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<PageHeader title="Skladové reporty" />
</motion.div>
<motion.div <Tabs
initial={{ opacity: 0, y: 12 }} value={activeTab}
animate={{ opacity: 1, y: 0 }} onChange={(v) => setActiveTab(v as TabId)}
transition={{ duration: 0.25, delay: 0.06 }} tabs={TABS}
> />
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as TabId)}
tabs={TABS}
/>
</motion.div>
<TabPanel value="stock-status" current={activeTab}> <TabPanel value="stock-status" current={activeTab}>
<StockStatusTab categoryId={categoryId} setCategoryId={setCategoryId} /> <StockStatusTab categoryId={categoryId} setCategoryId={setCategoryId} />
@@ -139,7 +127,7 @@ export default function WarehouseReports() {
<TabPanel value="below-minimum" current={activeTab}> <TabPanel value="below-minimum" current={activeTab}>
<BelowMinimumTab /> <BelowMinimumTab />
</TabPanel> </TabPanel>
</Box> </PageEnter>
); );
} }

View File

@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
@@ -29,6 +28,7 @@ import {
Select, Select,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter,
FilterBar, FilterBar,
EmptyState, EmptyState,
LoadingState, LoadingState,
@@ -325,27 +325,21 @@ export default function WarehouseReservations() {
]; ];
return ( return (
<Box> <PageEnter>
<motion.div <PageHeader
initial={{ opacity: 0, y: 12 }} title="Rezervace"
animate={{ opacity: 1, y: 0 }} subtitle={subtitle}
transition={{ duration: 0.25 }} actions={
> canOperate ? (
<PageHeader <Button
title="Rezervace" startIcon={PlusIcon}
subtitle={subtitle} onClick={() => setShowCreateModal(true)}
actions={ >
canOperate ? ( Nová rezervace
<Button </Button>
startIcon={PlusIcon} ) : undefined
onClick={() => setShowCreateModal(true)} }
> />
Nová rezervace
</Button>
) : undefined
}
/>
</motion.div>
<FilterBar> <FilterBar>
<Box sx={{ flex: "0 0 160px" }}> <Box sx={{ flex: "0 0 160px" }}>
@@ -490,6 +484,6 @@ export default function WarehouseReservations() {
confirmVariant="danger" confirmVariant="danger"
loading={cancelling} loading={cancelling}
/> />
</Box> </PageEnter>
); );
} }