477 lines
26 KiB
JavaScript
477 lines
26 KiB
JavaScript
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||
import ModalShell from '../ui/ModalShell';
|
||
|
||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function parseTime(str) {
|
||
if (!str || typeof str !== 'string') return null;
|
||
const match = str.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||
if (!match) return null;
|
||
const h = parseInt(match[1], 10);
|
||
const m = parseInt(match[2], 10);
|
||
if (h > 23 || m > 59) return null;
|
||
return h + m / 60;
|
||
}
|
||
|
||
function calcHours(fromStr, toStr) {
|
||
const from = parseTime(fromStr);
|
||
const to = parseTime(toStr);
|
||
if (from === null || to === null) return null;
|
||
let diff = to - from;
|
||
if (diff < 0) diff += 24; // overnight
|
||
return Math.max(0, diff);
|
||
}
|
||
|
||
function parseNum(value) {
|
||
if (value == null || value === '') return 0;
|
||
const n = Number(String(value).replace(',', '.'));
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
function fmtH(h) { return h.toFixed(1).replace('.', ','); }
|
||
|
||
/** Converts decimal hours back to "HH:MM" string. */
|
||
function formatTime(decimalHours) {
|
||
if (!Number.isFinite(decimalHours)) return '';
|
||
let h = ((decimalHours % 24) + 24) % 24;
|
||
const hours = Math.floor(h);
|
||
let mins = Math.round((h - hours) * 60);
|
||
if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`;
|
||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** Returns 'travel' | 'work' | 'empty' for a given row. */
|
||
function rowMode(row) {
|
||
if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel';
|
||
if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work';
|
||
return 'empty';
|
||
}
|
||
|
||
/**
|
||
* Applies mutual-exclusion and auto-calculation to a single row.
|
||
* Travel mode → clears work fields, computes travel_hours from times − break.
|
||
* Work mode → clears travel fields + vehicle_km, computes work_hours.
|
||
* Empty mode → clears both calculated totals.
|
||
*/
|
||
function computeRow(row) {
|
||
const r = { ...row };
|
||
const mode = rowMode(r);
|
||
|
||
if (mode === 'travel') {
|
||
r.work_time_from = '';
|
||
r.work_time_to = '';
|
||
r.work_hours = '';
|
||
const raw = calcHours(r.travel_time_from, r.travel_time_to);
|
||
r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
|
||
} else if (mode === 'work') {
|
||
r.travel_time_from = '';
|
||
r.travel_time_to = '';
|
||
r.travel_hours = '';
|
||
r.vehicle_km = '';
|
||
const raw = calcHours(r.work_time_from, r.work_time_to);
|
||
r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
|
||
} else {
|
||
r.work_hours = '';
|
||
r.travel_hours = '';
|
||
}
|
||
return r;
|
||
}
|
||
|
||
const INTERNAL = ['_groupId', '_role'];
|
||
function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; }
|
||
|
||
let _gidSeq = 0;
|
||
function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; }
|
||
|
||
// ─── constants ───────────────────────────────────────────────────────────────
|
||
|
||
const EMPTY_ROW = {
|
||
day: '', date: '',
|
||
work_time_from: '', work_time_to: '',
|
||
travel_time_from: '', travel_time_to: '',
|
||
break_hours: '',
|
||
work_hours: '', travel_hours: '',
|
||
departure_place: '', arrival_place: '',
|
||
vehicle_km: '',
|
||
};
|
||
|
||
/**
|
||
* computed: true → value is auto-calculated, never editable directly.
|
||
* travelOnly: true → disabled when mode === 'work'.
|
||
* workOnly: true → disabled when mode === 'travel'.
|
||
*/
|
||
const FIELD_CONFIGS = [
|
||
{ key: 'day', label: 'Dan', type: 'select' },
|
||
{ key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' },
|
||
{ key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true },
|
||
{ key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true },
|
||
{ key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true },
|
||
{ key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true },
|
||
{ key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' },
|
||
{ key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
|
||
{ key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
|
||
{ key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true },
|
||
{ key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true },
|
||
{ key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true },
|
||
];
|
||
|
||
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
|
||
|
||
const FB_INPUT = [
|
||
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
|
||
'text-gray-900 placeholder-gray-400',
|
||
'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||
'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400',
|
||
'dark:focus:border-blue-500 dark:focus:ring-blue-500',
|
||
'transition-colors duration-150',
|
||
].join(' ');
|
||
|
||
const FB_MUTED = [
|
||
'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs',
|
||
'text-gray-400 cursor-default select-none',
|
||
'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
|
||
].join(' ');
|
||
|
||
// ─── component ───────────────────────────────────────────────────────────────
|
||
|
||
export default function TaskWorkHoursTableModal({
|
||
open,
|
||
task,
|
||
defaultLocation = '',
|
||
saving = false,
|
||
onClose,
|
||
onSave,
|
||
}) {
|
||
const [rows, setRows] = useState([]);
|
||
|
||
useEffect(() => {
|
||
if (!open) { setRows([]); return; }
|
||
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
|
||
setRows(
|
||
incoming.length > 0
|
||
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
|
||
: [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
|
||
);
|
||
}, [open, task?.id, task?.work_hours_table]);
|
||
|
||
if (!open || !task) return null;
|
||
|
||
// ── derived totals ─────────────────────────────────────────────────────
|
||
const totals = useMemo(() => {
|
||
const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0);
|
||
const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0);
|
||
const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0);
|
||
return {
|
||
travel: fmtH(travel),
|
||
work: fmtH(work),
|
||
km: Number.isInteger(km) ? String(km) : km.toFixed(1),
|
||
};
|
||
}, [rows]);
|
||
|
||
// ── row mutation ───────────────────────────────────────────────────────
|
||
const updateRowField = (index, field, value) => {
|
||
setRows((prev) => {
|
||
// Step 1: update the changed row with full mutual-exclusion + calc
|
||
let updated = prev.map((row, i) => {
|
||
if (i !== index) return row;
|
||
return computeRow({ ...row, [field]: value });
|
||
});
|
||
|
||
const changed = updated[index];
|
||
const gid = changed?._groupId;
|
||
const role = changed?._role;
|
||
if (!gid) return updated;
|
||
|
||
// Step 2: departure row changes → sync return row
|
||
if (role === 'departure') {
|
||
updated = updated.map((row, i) => {
|
||
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
|
||
|
||
let returnRow = { ...row };
|
||
|
||
// Mirror day/date always
|
||
if (field === 'day' || field === 'date') {
|
||
returnRow[field] = value;
|
||
}
|
||
|
||
// Mirror break_hours
|
||
if (field === 'break_hours') {
|
||
returnRow.break_hours = value;
|
||
}
|
||
|
||
// Mirror vehicle_km
|
||
if (field === 'vehicle_km') {
|
||
returnRow.vehicle_km = value;
|
||
}
|
||
|
||
// Mirror travel times + recalculate return travel_to from duration
|
||
if (field === 'travel_time_from' || field === 'travel_time_to') {
|
||
returnRow.travel_time_from = changed.travel_time_from;
|
||
// Recalculate return's travel_to: return departs when work ends (travel_time_from stays)
|
||
// and duration equals departure trip duration
|
||
const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to);
|
||
if (depDuration !== null && returnRow.travel_time_from) {
|
||
const retFrom = parseTime(returnRow.travel_time_from);
|
||
if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration);
|
||
}
|
||
}
|
||
|
||
// Mirror places (swapped): arrival of departure → departure of return
|
||
if (field === 'arrival_place') {
|
||
returnRow.departure_place = value;
|
||
}
|
||
if (field === 'departure_place') {
|
||
returnRow.arrival_place = value;
|
||
}
|
||
|
||
return computeRow(returnRow);
|
||
});
|
||
}
|
||
|
||
// Step 3: work row's work_time_to → fill return row's travel_time_from
|
||
// and recalculate return row's travel_time_to using departure duration
|
||
if (role === 'work' && field === 'work_time_to') {
|
||
const depRow = updated.find(r => r._groupId === gid && r._role === 'departure');
|
||
const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null;
|
||
|
||
updated = updated.map((row, i) => {
|
||
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
|
||
const newFrom = value; // Put od = when work ends
|
||
const newTo = (depDuration !== null && parseTime(newFrom) !== null)
|
||
? formatTime(parseTime(newFrom) + depDuration)
|
||
: row.travel_time_to;
|
||
return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo });
|
||
});
|
||
}
|
||
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||
|
||
const addThreeRows = () => {
|
||
const today = new Date();
|
||
const dd = String(today.getDate()).padStart(2, '0');
|
||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
|
||
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
|
||
const dayLabel = dayLabels[today.getDay()];
|
||
const gid = newGroupId();
|
||
const loc = defaultLocation || '';
|
||
|
||
setRows((prev) => [
|
||
...prev,
|
||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
|
||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
|
||
]);
|
||
};
|
||
|
||
const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||
|
||
const handleSave = async () => {
|
||
await onSave?.({ rows: rows.map(stripInternal) });
|
||
};
|
||
|
||
// ── field rendering ────────────────────────────────────────────────────
|
||
const isDisabled = (row, f) => {
|
||
const mode = rowMode(row);
|
||
if (f.computed) return true;
|
||
if (f.travelOnly && mode === 'work') return true;
|
||
if (f.workOnly && mode === 'travel') return true;
|
||
return false;
|
||
};
|
||
|
||
const renderCell = (row, idx, f) => {
|
||
const disabled = isDisabled(row, f);
|
||
const cls = disabled ? FB_MUTED : FB_INPUT;
|
||
|
||
if (f.type === 'select') {
|
||
return (
|
||
<select
|
||
value={row[f.key] || ''}
|
||
onChange={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
|
||
disabled={disabled}
|
||
className={cls}
|
||
>
|
||
{DAY_OPTIONS.map((o) => <option key={o || '_'} value={o}>{o || '-'}</option>)}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<input
|
||
type={f.type}
|
||
value={row[f.key] || ''}
|
||
onInput={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
|
||
readOnly={f.computed}
|
||
disabled={disabled}
|
||
className={cls}
|
||
placeholder={f.placeholder || ''}
|
||
step={f.step}
|
||
min={f.min}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// ── render ─────────────────────────────────────────────────────────────
|
||
return (
|
||
<ModalShell
|
||
onClose={onClose}
|
||
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
|
||
contentClassName="flex min-h-full items-start justify-center"
|
||
panelClassName="w-full max-w-[96vw] rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
|
||
>
|
||
<div className="flex w-full flex-col">
|
||
|
||
{/* ── header ── */}
|
||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-text-main">Tablica radnih sati</h3>
|
||
<p className="text-xs text-text-muted">{task.title || '-'}</p>
|
||
</div>
|
||
<button type="button" onClick={onClose} aria-label="Zatvori"
|
||
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700">✕</button>
|
||
</div>
|
||
|
||
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
||
|
||
{/* ── info banner ── */}
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
|
||
<span><span className="font-semibold">Put (od–do):</span> unesi Put od/do → Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>
|
||
<span><span className="font-semibold">Rad (od–do):</span> unesi Vr. rada od/do → Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani.</span>
|
||
<span><span className="font-semibold">Pauza</span> umanjuje izračunate sate u oba slučaja.</span>
|
||
</div>
|
||
|
||
{/* ── toolbar ── */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<button type="button" onClick={addRow}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
+ Dodaj redak
|
||
</button>
|
||
<button type="button" onClick={addThreeRows}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50">
|
||
+ Dodaj standardna 3 retka (Put / Rad / Povratak)
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── mobile cards (< xl) ── */}
|
||
<div className="space-y-3 xl:hidden">
|
||
{rows.length === 0 && (
|
||
<p className="text-sm text-gray-400 dark:text-gray-500">Nema unesenih redaka.</p>
|
||
)}
|
||
{rows.map((row, idx) => (
|
||
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<span className="text-xs font-bold text-gray-500 dark:text-gray-400">#{idx + 1}</span>
|
||
<button type="button" onClick={() => removeRow(idx)}
|
||
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
|
||
Obriši
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{FIELD_CONFIGS.map((f) => (
|
||
<label key={f.key} className="flex flex-col gap-0.5">
|
||
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{f.label}</span>
|
||
{renderCell(row, idx, f)}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{rows.length > 0 && (
|
||
<div className="rounded-lg border border-gray-300 bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200">
|
||
UKUPNO —
|
||
<span className="ml-2">Sati rada: <span className="text-green-700 dark:text-green-400">{totals.work} h</span></span>
|
||
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
|
||
<span>Sati puta: <span className="text-blue-700 dark:text-blue-400">{totals.travel} h</span></span>
|
||
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
|
||
<span>Km vozila: <span className="text-gray-800 dark:text-gray-100">{totals.km} km</span></span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── desktop table (≥ xl) ── */}
|
||
<div className="hidden xl:block">
|
||
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700"> <table className="min-w-[1580px] w-full bg-white text-xs dark:bg-gray-900">
|
||
<thead>
|
||
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
|
||
<th className="w-8 px-2 py-2.5 text-center text-gray-400 dark:text-gray-500">#</th>
|
||
{FIELD_CONFIGS.map((f) => (
|
||
<th key={f.key} className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
|
||
{f.label}
|
||
{f.computed && <span className="ml-1 text-[9px] font-normal text-indigo-400">(auto)</span>}
|
||
</th>
|
||
))}
|
||
<th className="px-2 py-2.5 text-gray-400 dark:text-gray-500">Akcija</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td colSpan={FIELD_CONFIGS.length + 2}
|
||
className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||
Nema unesenih redaka.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{rows.map((row, idx) => (
|
||
<tr key={idx}
|
||
className="border-t border-gray-100 odd:bg-white even:bg-gray-50/60 hover:bg-blue-50/30 dark:border-gray-700 dark:odd:bg-gray-900 dark:even:bg-gray-800/60 dark:hover:bg-blue-900/10">
|
||
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400 dark:text-gray-500">{idx + 1}</td>
|
||
{FIELD_CONFIGS.map((f) => (
|
||
<td key={f.key} className="px-1 py-1 align-middle">
|
||
{renderCell(row, idx, f)}
|
||
</td>
|
||
))}
|
||
<td className="px-1 py-1 align-middle">
|
||
<button type="button" onClick={() => removeRow(idx)}
|
||
className="whitespace-nowrap rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
|
||
Obriši
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{rows.length > 0 && (
|
||
/* cols: # day date work_from work_to travel_from travel_to break → 8 cols
|
||
then: work_hours travel_hours departure arrival vehicle_km action */
|
||
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
|
||
<td colSpan={8} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO</td>
|
||
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
|
||
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati rada:</span>{totals.work} h
|
||
</td>
|
||
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
|
||
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati puta:</span>{totals.travel} h
|
||
</td>
|
||
<td colSpan={2} className="px-2 py-2" />
|
||
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
|
||
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Km:</span>{totals.km}
|
||
</td>
|
||
<td className="px-2 py-2" />
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* ── footer ── */}
|
||
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
|
||
<button type="button" onClick={onClose} disabled={saving}
|
||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
|
||
Odustani
|
||
</button>
|
||
<button type="button" onClick={handleSave} disabled={saving}
|
||
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||
{saving ? 'Spremam…' : 'Spremi tablicu radnih sati'}
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</ModalShell>
|
||
);
|
||
}
|