fix: omoguci rucni unos vremena u mjesecnom izvjestaju
Dodaje uredski unos pocetka i kraja rada u sidebaru mjesecnog izvjestaja servisera i automatski racuna redovan rad iz unesenog raspona. Zadrzava postojece predpopunjavanje za rad u uredu i validira neispravan vremenski raspon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -748,25 +748,47 @@ class MonthlyServicerDayEntry(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
|
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
|
||||||
|
|
||||||
|
def _calculate_regular_hours(self, start_time, end_time):
|
||||||
|
if start_time is None or end_time is None:
|
||||||
|
return Decimal('8.00')
|
||||||
|
|
||||||
|
start_minutes = (start_time.hour * 60) + start_time.minute + (start_time.second / 60)
|
||||||
|
end_minutes = (end_time.hour * 60) + end_time.minute + (end_time.second / 60)
|
||||||
|
if end_minutes <= start_minutes:
|
||||||
|
raise ValidationError({
|
||||||
|
'end_time': _('Kraj rada mora biti nakon početka rada.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
hours = Decimal(str((end_minutes - start_minutes) / 60)).quantize(Decimal('0.01'))
|
||||||
|
return hours
|
||||||
|
|
||||||
def apply_defaults(self):
|
def apply_defaults(self):
|
||||||
|
start_time = self.start_time
|
||||||
|
end_time = self.end_time
|
||||||
|
|
||||||
if self.entry_type == 'office':
|
if self.entry_type == 'office':
|
||||||
self.description = 'Rad u uredu'
|
if not self.description:
|
||||||
self.location = 'Zagreb (ured)'
|
self.description = 'Rad u uredu'
|
||||||
self.start_time = time(8, 0)
|
if not self.location:
|
||||||
self.end_time = time(16, 0)
|
self.location = 'Zagreb (ured)'
|
||||||
self.regular_hours = Decimal('8.00')
|
if start_time is None:
|
||||||
self.overtime_hours = Decimal('0.00')
|
start_time = time(8, 0)
|
||||||
return
|
if end_time is None:
|
||||||
|
end_time = time(16, 0)
|
||||||
if self.entry_type == 'vacation':
|
elif self.entry_type == 'vacation':
|
||||||
self.description = 'Godišnji odmor'
|
if not self.description:
|
||||||
|
self.description = 'Godišnji odmor'
|
||||||
|
self.location = self.location or ''
|
||||||
elif self.entry_type == 'sick_leave':
|
elif self.entry_type == 'sick_leave':
|
||||||
self.description = 'Bolovanje'
|
if not self.description:
|
||||||
|
self.description = 'Bolovanje'
|
||||||
|
self.location = self.location or ''
|
||||||
|
else:
|
||||||
|
self.location = self.location or ''
|
||||||
|
|
||||||
self.location = ''
|
self.start_time = start_time
|
||||||
self.start_time = None
|
self.end_time = end_time
|
||||||
self.end_time = None
|
self.regular_hours = self._calculate_regular_hours(start_time, end_time)
|
||||||
self.regular_hours = Decimal('8.00')
|
|
||||||
self.overtime_hours = Decimal('0.00')
|
self.overtime_hours = Decimal('0.00')
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|||||||
@@ -250,8 +250,6 @@ class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer):
|
|||||||
'id',
|
'id',
|
||||||
'description',
|
'description',
|
||||||
'location',
|
'location',
|
||||||
'start_time',
|
|
||||||
'end_time',
|
|
||||||
'regular_hours',
|
'regular_hours',
|
||||||
'overtime_hours',
|
'overtime_hours',
|
||||||
'created_at',
|
'created_at',
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ function formatTime(value) {
|
|||||||
return parsed.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' });
|
return parsed.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toTimeInputValue(value) {
|
||||||
|
const formatted = formatTime(value);
|
||||||
|
return formatted === '-' ? '' : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateHours(startTime, endTime) {
|
||||||
|
if (!startTime || !endTime) return null;
|
||||||
|
const [startHour, startMinute] = String(startTime).split(':').map((part) => Number(part));
|
||||||
|
const [endHour, endMinute] = String(endTime).split(':').map((part) => Number(part));
|
||||||
|
if (![startHour, startMinute, endHour, endMinute].every((value) => Number.isFinite(value))) return null;
|
||||||
|
const startTotal = (startHour * 60) + startMinute;
|
||||||
|
const endTotal = (endHour * 60) + endMinute;
|
||||||
|
if (endTotal <= startTotal) return null;
|
||||||
|
return ((endTotal - startTotal) / 60).toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function formatHourValue(value, fallback = '-') {
|
function formatHourValue(value, fallback = '-') {
|
||||||
if (value == null || value === '') return fallback;
|
if (value == null || value === '') return fallback;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -70,6 +86,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
const [loadingManualEntries, setLoadingManualEntries] = useState(false);
|
const [loadingManualEntries, setLoadingManualEntries] = useState(false);
|
||||||
const [loadingCostInvoices, setLoadingCostInvoices] = useState(false);
|
const [loadingCostInvoices, setLoadingCostInvoices] = useState(false);
|
||||||
const [manualEntryTarget, setManualEntryTarget] = useState(null);
|
const [manualEntryTarget, setManualEntryTarget] = useState(null);
|
||||||
|
const [manualEntryDraft, setManualEntryDraft] = useState(null);
|
||||||
|
const [manualEntryError, setManualEntryError] = useState('');
|
||||||
const [savingManualEntry, setSavingManualEntry] = useState(false);
|
const [savingManualEntry, setSavingManualEntry] = useState(false);
|
||||||
|
|
||||||
const reportOpen = reportType !== null;
|
const reportOpen = reportType !== null;
|
||||||
@@ -342,15 +360,36 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
function handleServicerRowClick(row) {
|
function handleServicerRowClick(row) {
|
||||||
if (!row?.clickable) return;
|
if (!row?.clickable) return;
|
||||||
setManualEntryTarget(row);
|
setManualEntryTarget(row);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveManualEntry(entryType) {
|
function startManualEntry(entryType) {
|
||||||
if (!manualEntryTarget || savingManualEntry) return;
|
if (!manualEntryTarget) return;
|
||||||
|
const isOffice = entryType === 'office';
|
||||||
|
setManualEntryError('');
|
||||||
|
setManualEntryDraft({
|
||||||
|
entryType,
|
||||||
|
startTime: isOffice ? '08:00' : '',
|
||||||
|
endTime: isOffice ? '16:00' : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveManualEntry() {
|
||||||
|
if (!manualEntryTarget || !manualEntryDraft || savingManualEntry) return;
|
||||||
|
const { entryType, startTime, endTime } = manualEntryDraft;
|
||||||
|
if (startTime && endTime && !calculateHours(startTime, endTime)) {
|
||||||
|
setManualEntryError('Kraj rada mora biti nakon početka rada.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setManualEntryError('');
|
||||||
setSavingManualEntry(true);
|
setSavingManualEntry(true);
|
||||||
try {
|
try {
|
||||||
const saved = await upsertMonthlyServicerEntry({
|
const saved = await upsertMonthlyServicerEntry({
|
||||||
entry_date: manualEntryTarget.key,
|
entry_date: manualEntryTarget.key,
|
||||||
entry_type: entryType,
|
entry_type: entryType,
|
||||||
|
start_time: startTime || undefined,
|
||||||
|
end_time: endTime || undefined,
|
||||||
});
|
});
|
||||||
setManualEntries((prev) => {
|
setManualEntries((prev) => {
|
||||||
const next = Array.isArray(prev) ? [...prev] : [];
|
const next = Array.isArray(prev) ? [...prev] : [];
|
||||||
@@ -363,6 +402,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
return next.sort((a, b) => String(a.entry_date || '').localeCompare(String(b.entry_date || '')));
|
return next.sort((a, b) => String(a.entry_date || '').localeCompare(String(b.entry_date || '')));
|
||||||
});
|
});
|
||||||
setManualEntryTarget(null);
|
setManualEntryTarget(null);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingManualEntry(false);
|
setSavingManualEntry(false);
|
||||||
}
|
}
|
||||||
@@ -679,14 +720,20 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
<div className="w-full max-w-sm rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
<div className="w-full max-w-sm rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-main">Odaberi vrstu unosa</h3>
|
<h3 className="text-sm font-semibold text-text-main">
|
||||||
|
{manualEntryDraft ? 'Unos radnog dana' : 'Odaberi vrstu unosa'}
|
||||||
|
</h3>
|
||||||
<p className="mt-1 text-xs text-text-muted">
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.
|
{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setManualEntryTarget(null)}
|
onClick={() => {
|
||||||
|
setManualEntryTarget(null);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
}}
|
||||||
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
||||||
aria-label="Zatvori"
|
aria-label="Zatvori"
|
||||||
>
|
>
|
||||||
@@ -694,35 +741,87 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-2">
|
{!manualEntryDraft ? (
|
||||||
<button
|
<div className="mt-4 grid gap-2">
|
||||||
type="button"
|
<button
|
||||||
disabled={savingManualEntry}
|
type="button"
|
||||||
onClick={() => handleSaveManualEntry('office')}
|
disabled={savingManualEntry}
|
||||||
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
onClick={() => startManualEntry('office')}
|
||||||
>
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
<span className="block font-medium text-text-main">Rad u uredu</span>
|
>
|
||||||
<span className="block text-xs text-text-muted">08:00 - 16:00, 8h, Zagreb (ured)</span>
|
<span className="block font-medium text-text-main">Rad u uredu</span>
|
||||||
</button>
|
<span className="block text-xs text-text-muted">08:00 - 16:00, 8h, Zagreb (ured)</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
disabled={savingManualEntry}
|
type="button"
|
||||||
onClick={() => handleSaveManualEntry('sick_leave')}
|
disabled={savingManualEntry}
|
||||||
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
onClick={() => startManualEntry('sick_leave')}
|
||||||
>
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
<span className="block font-medium text-text-main">Bolovanje</span>
|
>
|
||||||
<span className="block text-xs text-text-muted">Ukupno 8h</span>
|
<span className="block font-medium text-text-main">Bolovanje</span>
|
||||||
</button>
|
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
disabled={savingManualEntry}
|
type="button"
|
||||||
onClick={() => handleSaveManualEntry('vacation')}
|
disabled={savingManualEntry}
|
||||||
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
onClick={() => startManualEntry('vacation')}
|
||||||
>
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
<span className="block font-medium text-text-main">Godisnji</span>
|
>
|
||||||
<span className="block text-xs text-text-muted">Ukupno 8h</span>
|
<span className="block font-medium text-text-main">Godisnji</span>
|
||||||
</button>
|
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Početak rada</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={manualEntryDraft.startTime}
|
||||||
|
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, startTime: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Kraj rada</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={manualEntryDraft.endTime}
|
||||||
|
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, endTime: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-canvas-deep px-3 py-2 text-xs text-text-muted">
|
||||||
|
Redovan rad (h): {calculateHours(manualEntryDraft.startTime, manualEntryDraft.endTime) || '0'}
|
||||||
|
</div>
|
||||||
|
{manualEntryError && (
|
||||||
|
<p className="text-xs text-red-600">{manualEntryError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={() => {
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-lg border border-border-hairline px-3 py-2 text-sm hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Nazad
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={handleSaveManualEntry}
|
||||||
|
className="flex-1 rounded-lg bg-indigo-600 px-3 py-2 text-sm text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingManualEntry ? 'Spremam...' : 'Spremi'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user