feat(plan): grid component with sticky headers and weekend tint
This commit is contained in:
86
src/admin/components/PlanGrid.tsx
Normal file
86
src/admin/components/PlanGrid.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { GridData, ResolvedCell } from "../lib/queries/plan";
|
||||||
|
import PlanRangeChips from "./PlanRangeChips";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: GridData | undefined;
|
||||||
|
canEdit: boolean;
|
||||||
|
onCellClick: (
|
||||||
|
userId: number,
|
||||||
|
date: string,
|
||||||
|
cell: ResolvedCell | null,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CZECH_WEEKDAYS = ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"];
|
||||||
|
|
||||||
|
function eachDay(from: string, to: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const a = new Date(from + "T00:00:00.000Z");
|
||||||
|
const b = new Date(to + "T00:00:00.000Z");
|
||||||
|
for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
out.push(d.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function czechWeekday(dateStr: string): string {
|
||||||
|
return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekend(dateStr: string): boolean {
|
||||||
|
const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanGrid({ data, canEdit, onCellClick }: Props) {
|
||||||
|
if (!data)
|
||||||
|
return (
|
||||||
|
<div className="admin-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const days = eachDay(data.date_from, data.date_to);
|
||||||
|
const users = data.users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plan-grid-wrap">
|
||||||
|
<table className="plan-grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="plan-grid-date-col">Datum</th>
|
||||||
|
{users.map((u) => (
|
||||||
|
<th key={u.id}>{u.full_name}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{days.map((date) => (
|
||||||
|
<tr
|
||||||
|
key={date}
|
||||||
|
className={isWeekend(date) ? "plan-grid-weekend" : ""}
|
||||||
|
>
|
||||||
|
<td className="plan-grid-date-col">
|
||||||
|
{czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
|
||||||
|
</td>
|
||||||
|
{users.map((u) => {
|
||||||
|
const cell = data.cells[u.id]?.[date] ?? null;
|
||||||
|
const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`;
|
||||||
|
return (
|
||||||
|
<td key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
onClick={() => onCellClick(u.id, date, cell)}
|
||||||
|
>
|
||||||
|
<PlanRangeChips cell={cell} readonly={!canEdit} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/admin/components/PlanRangeChips.tsx
Normal file
33
src/admin/components/PlanRangeChips.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ResolvedCell } from "../lib/queries/plan";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cell: ResolvedCell | null;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
work: "Práce",
|
||||||
|
preparation: "Příprava",
|
||||||
|
travel: "Cesta / Montáž",
|
||||||
|
leave: "Dovolená",
|
||||||
|
sick: "Nemoc",
|
||||||
|
training: "Školení",
|
||||||
|
other: "Jiné",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlanRangeChips({ cell, readonly }: Props) {
|
||||||
|
if (!cell) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className={`plan-chip plan-chip--${cell.category}`}>
|
||||||
|
{CATEGORY_LABELS[cell.category] ?? cell.category}
|
||||||
|
</span>
|
||||||
|
{cell.project_id && (
|
||||||
|
<span style={{ marginLeft: 6, fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{`#${cell.project_id}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cell.note && <span className="plan-cell-note">{cell.note}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/admin/plan.css
Normal file
125
src/admin/plan.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* === Plán prací (work schedule) module === */
|
||||||
|
|
||||||
|
.plan-grid-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: calc(100vh - 240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border, #e5e7eb);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid thead th.plan-grid-date-col {
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid tbody td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border, #e5e7eb);
|
||||||
|
vertical-align: top;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid tbody td.plan-grid-date-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-grid tr.plan-grid-weekend td {
|
||||||
|
background: var(--bg-weekend, #fefce8);
|
||||||
|
}
|
||||||
|
.plan-grid tr.plan-grid-weekend td.plan-grid-date-col {
|
||||||
|
background: var(--bg-weekend-strong, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-cell {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.plan-cell:hover {
|
||||||
|
border-color: var(--primary, #2563eb);
|
||||||
|
}
|
||||||
|
.plan-cell--readonly {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.plan-cell--readonly:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
background: var(--chip-default, #e5e7eb);
|
||||||
|
}
|
||||||
|
.plan-chip--work {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
.plan-chip--preparation {
|
||||||
|
background: #ccfbf1;
|
||||||
|
}
|
||||||
|
.plan-chip--travel {
|
||||||
|
background: #fde68a;
|
||||||
|
}
|
||||||
|
.plan-chip--leave {
|
||||||
|
background: #bbf7d0;
|
||||||
|
}
|
||||||
|
.plan-chip--sick {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
.plan-chip--training {
|
||||||
|
background: #e9d5ff;
|
||||||
|
}
|
||||||
|
.plan-chip--other {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-cell-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user