- listAttendance() now maps users.first_name + last_name to user_name - Fixed escaped Unicode in table headers (Zaměstnanec, Příchod, Poznámka) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { Link } from 'react-router-dom'
|
|
import {
|
|
formatDate, formatDatetime, formatTime,
|
|
calculateWorkMinutes, formatMinutes,
|
|
getLeaveTypeName, getLeaveTypeBadgeClass
|
|
} from '../utils/attendanceHelpers'
|
|
|
|
interface ProjectLog {
|
|
id?: number
|
|
project_id?: number
|
|
project_name?: string
|
|
started_at?: string
|
|
ended_at?: string | null
|
|
hours?: string | number | null
|
|
minutes?: string | number | null
|
|
}
|
|
|
|
interface AttendanceRecord {
|
|
id: number
|
|
shift_date: string
|
|
user_name: string
|
|
leave_type?: string
|
|
leave_hours?: number
|
|
arrival_time?: string | null
|
|
departure_time?: string | null
|
|
break_start?: string | null
|
|
break_end?: string | null
|
|
arrival_lat?: number | string | null
|
|
arrival_lng?: number | string | null
|
|
departure_lat?: number | string | null
|
|
departure_lng?: number | string | null
|
|
project_name?: string
|
|
project_logs?: ProjectLog[]
|
|
notes?: string | null
|
|
}
|
|
|
|
interface AttendanceShiftTableProps {
|
|
records: AttendanceRecord[]
|
|
onEdit: (record: AttendanceRecord) => void
|
|
onDelete: (record: AttendanceRecord) => void
|
|
}
|
|
|
|
function formatBreak(record: AttendanceRecord): string {
|
|
if (record.break_start && record.break_end) {
|
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
|
|
}
|
|
if (record.break_start) {
|
|
return `${formatTime(record.break_start)} - ?`
|
|
}
|
|
return '\u2014'
|
|
}
|
|
|
|
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|
if (record.project_logs && record.project_logs.length > 0) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
|
|
{record.project_logs.map((log, i) => {
|
|
let h: number, m: number, isActive = false
|
|
if (log.hours !== null && log.hours !== undefined) {
|
|
h = parseInt(String(log.hours)) || 0
|
|
m = parseInt(String(log.minutes)) || 0
|
|
} else {
|
|
isActive = !log.ended_at
|
|
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
|
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
|
|
h = Math.floor(mins / 60)
|
|
m = mins % 60
|
|
}
|
|
return (
|
|
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
|
|
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' \u25B8' : ''})
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
if (record.project_name) {
|
|
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
|
|
}
|
|
return '\u2014'
|
|
}
|
|
|
|
export default function AttendanceShiftTable({ records, onEdit, onDelete }: AttendanceShiftTableProps) {
|
|
if (records.length === 0) {
|
|
return (
|
|
<div className="admin-empty-state">
|
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Datum</th>
|
|
<th>Zaměstnanec</th>
|
|
<th>Typ</th>
|
|
<th>Příchod</th>
|
|
<th>Pauza</th>
|
|
<th>Odchod</th>
|
|
<th>Hodiny</th>
|
|
<th>Projekt</th>
|
|
<th>GPS</th>
|
|
<th>Poznámka</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.map((record) => {
|
|
const leaveType = record.leave_type || 'work'
|
|
const isLeave = leaveType !== 'work'
|
|
const workMinutes = isLeave
|
|
? (Number(record.leave_hours) || 8) * 60
|
|
: calculateWorkMinutes(record)
|
|
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
|
|
|
|
return (
|
|
<tr key={record.id}>
|
|
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
|
<td>{record.user_name}</td>
|
|
<td>
|
|
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
|
|
{getLeaveTypeName(leaveType)}
|
|
</span>
|
|
</td>
|
|
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.arrival_time)}</td>
|
|
<td className="admin-mono">
|
|
{isLeave ? '\u2014' : formatBreak(record)}
|
|
</td>
|
|
<td className="admin-mono">{isLeave ? '\u2014' : formatDatetime(record.departure_time)}</td>
|
|
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '\u2014'}</td>
|
|
<td>
|
|
{renderProjectCell(record)}
|
|
</td>
|
|
<td>
|
|
{hasLocation ? (
|
|
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
|
|
{'\uD83D\uDCCD'}
|
|
</Link>
|
|
) : '\u2014'}
|
|
</td>
|
|
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
|
|
{record.notes || ''}
|
|
</td>
|
|
<td>
|
|
<div className="admin-table-actions">
|
|
<button
|
|
onClick={() => onEdit(record)}
|
|
className="admin-btn-icon"
|
|
title="Upravit"
|
|
aria-label="Upravit"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(record)}
|
|
className="admin-btn-icon danger"
|
|
title="Smazat"
|
|
aria-label="Smazat"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|