import { useCallback, useEffect, useMemo, useState } from 'react' import { Navigate } from 'react-router-dom' import { ClipboardList, Play, Plus, RotateCcw, Search, Square, Trash2, } from 'lucide-react' import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui' import { EmptyTableRow } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { RollCallTasksModal } from '../components/RollCallTasksModal' import { Tooltip } from '../components/Tooltip' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' import { useT } from '../i18n/useT' import { ApiError, isAbortError } from '../lib/api' import { getActiveBusinessId } from '../lib/businessContext' import { createRollCall, listRollCalls, removeRollCall, updateRollCall, type RollCall, type RollCallSummary, type RollCallsListResponse, } from '../services/rollCallService' import { canAccessRollCall } from '../utils/rollCallAccess' import pageStyles from '../components/PageContent.module.css' import styles from './RollCallPage.module.css' const PAGE_SIZE = 24 const COLUMN_COUNT = 5 const ACTIVE_KEY = 'meshkee_roll_call_active_id' function todayIso() { const now = new Date() const y = now.getFullYear() const m = String(now.getMonth() + 1).padStart(2, '0') const d = String(now.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } function nowTimeHhMm() { const now = new Date() return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` } function timeToMinutes(value: string) { const [h, m] = value.split(':').map(Number) return h * 60 + m } function minutesSinceStart(startTime: string, now = new Date()) { const start = timeToMinutes(startTime) const current = now.getHours() * 60 + now.getMinutes() if (current >= start) return current - start return 24 * 60 - start + current } function formatDuration( minutes: number, t: ( key: | 'rollCall.duration.minutesOnly' | 'rollCall.duration.hoursOnly' | 'rollCall.duration.hoursMinutes', vars?: Record, ) => string, ) { const hours = Math.floor(minutes / 60) const mins = minutes % 60 if (hours === 0) return t('rollCall.duration.minutesOnly', { minutes: mins }) if (mins === 0) return t('rollCall.duration.hoursOnly', { hours }) return t('rollCall.duration.hoursMinutes', { hours, minutes: mins }) } function formatDateLabel(value: string, locale: string) { const date = new Date(`${value}T00:00:00`) if (Number.isNaN(date.getTime())) return value return new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-GB', { year: 'numeric', month: 'short', day: 'numeric', }).format(date) } function readActiveId() { return localStorage.getItem(ACTIVE_KEY) } function writeActiveId(id: string | null) { if (id) localStorage.setItem(ACTIVE_KEY, id) else localStorage.removeItem(ACTIVE_KEY) } export function RollCallPage() { const t = useT() const { locale } = useLocale() const { showToast } = useToast() const { user } = useAuth() const activeBusinessId = getActiveBusinessId() const allowed = canAccessRollCall(user, activeBusinessId) const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [starting, setStarting] = useState(false) const [stoppingId, setStoppingId] = useState(null) const [error, setError] = useState('') const [page, setPage] = useState(1) const [draftDateFrom, setDraftDateFrom] = useState('') const [draftDateTo, setDraftDateTo] = useState('') const [appliedDateFrom, setAppliedDateFrom] = useState('') const [appliedDateTo, setAppliedDateTo] = useState('') const [tasksModalOpen, setTasksModalOpen] = useState(false) const [tasksTarget, setTasksTarget] = useState(null) const [removingId, setRemovingId] = useState(null) const [activeId, setActiveId] = useState(() => readActiveId()) const [nowTick, setNowTick] = useState(() => Date.now()) const load = useCallback( async (signal?: AbortSignal) => { setLoading(true) setError('') try { const response = await listRollCalls( { page, pageSize: PAGE_SIZE, ...(appliedDateFrom ? { dateFrom: appliedDateFrom } : {}), ...(appliedDateTo ? { dateTo: appliedDateTo } : {}), }, signal, ) if (signal?.aborted) return setData(response) } catch (err) { if (isAbortError(err) || signal?.aborted) return setError(err instanceof ApiError ? err.message : t('rollCall.error.load')) } finally { if (!signal?.aborted) setLoading(false) } }, [page, appliedDateFrom, appliedDateTo, t], ) useEffect(() => { if (!allowed) return const controller = new AbortController() void load(controller.signal) return () => controller.abort() }, [allowed, load]) useEffect(() => { if (!activeId) return const timer = window.setInterval(() => setNowTick(Date.now()), 15_000) return () => window.clearInterval(timer) }, [activeId]) const totalPages = useMemo(() => { const total = data?.total ?? 0 return Math.max(1, Math.ceil(total / PAGE_SIZE)) }, [data?.total]) const summary: RollCallSummary = data?.summary ?? { totalDays: 0, totalWorkingMinutes: 0, totalTasks: 0, } const hasActiveSession = Boolean(activeId) const activeItem = data?.items.find((item) => item.id === activeId) ?? null if (!allowed) { return } function applyFilters() { setAppliedDateFrom(draftDateFrom) setAppliedDateTo(draftDateTo) setPage(1) } function clearFilters() { setDraftDateFrom('') setDraftDateTo('') setAppliedDateFrom('') setAppliedDateTo('') setPage(1) } function upsertItem(saved: RollCall) { setData((prev) => { if (!prev) return prev const index = prev.items.findIndex((item) => item.id === saved.id) if (index >= 0) { const items = [...prev.items] items[index] = saved return { ...prev, items } } return { ...prev, items: [saved, ...prev.items], total: prev.total + 1, } }) } function openTasks(item: RollCall) { setTasksTarget(item) setTasksModalOpen(true) } async function handleStart() { if (hasActiveSession) { showToast(t('rollCall.error.alreadyRunning'), 'info') return } setStarting(true) setError('') const workDate = todayIso() const startTime = nowTimeHhMm() try { const existingToday = data?.items.find((item) => item.workDate === workDate) if (existingToday) { writeActiveId(existingToday.id) setActiveId(existingToday.id) showToast(t('rollCall.toast.resumed'), 'success') return } const result = await createRollCall({ workDate, startTime, endTime: startTime, tasks: [], }) writeActiveId(result.rollCall.id) setActiveId(result.rollCall.id) upsertItem(result.rollCall) showToast(t('rollCall.toast.started'), 'success') void load() } catch (err) { const message = err instanceof ApiError ? err.message : t('rollCall.error.start') setError(message) showToast(message, 'error') } finally { setStarting(false) } } async function handleStop(item: Pick) { setStoppingId(item.id) setError('') try { const result = await updateRollCall(item.id, { endTime: nowTimeHhMm() }) writeActiveId(null) setActiveId(null) upsertItem(result.rollCall) showToast(t('rollCall.toast.stopped'), 'success') void load() } catch (err) { const message = err instanceof ApiError ? err.message : t('rollCall.error.stop') setError(message) showToast(message, 'error') } finally { setStoppingId(null) } } async function handleRemove(item: RollCall) { setRemovingId(item.id) setError('') try { await removeRollCall(item.id) if (activeId === item.id) { writeActiveId(null) setActiveId(null) } showToast(t('rollCall.toast.removed'), 'success') void load() } catch (err) { const message = err instanceof ApiError ? err.message : t('rollCall.error.remove') setError(message) showToast(message, 'error') } finally { setRemovingId(null) } } function spentMinutes(item: RollCall) { void nowTick if (activeId === item.id) return minutesSinceStart(item.startTime) return item.workingMinutes } return (
{t('rollCall.title')}

{t('rollCall.subtitle')}

{ e.preventDefault() applyFilters() }} >
setDraftDateFrom(e.target.value)} aria-label={t('rollCall.filter.dateFrom')} title={t('rollCall.filter.dateFrom')} />
setDraftDateTo(e.target.value)} aria-label={t('rollCall.filter.dateTo')} title={t('rollCall.filter.dateTo')} />
{t('rollCall.summary.days')} {summary.totalDays}
{t('rollCall.summary.hours')} {formatDuration(summary.totalWorkingMinutes, t)}
{t('rollCall.summary.tasks')} {summary.totalTasks}
{t('rollCall.listTitle')}
{error &&
{error}
} {loading && ( )} {!loading && data?.items.length === 0 && ( )} {!loading && data?.items.map((item) => { const running = activeId === item.id const minutes = spentMinutes(item) return ( ) })}
{t('rollCall.col.date')} {t('rollCall.col.start')} {t('rollCall.col.minutes')} {t('rollCall.col.tasks')} {t('rollCall.col.actions')}
{t('rollCall.loading')}
{formatDateLabel(item.workDate, locale)} {running && ( {t('rollCall.running')} )} {item.startTime} {minutes} {t('rollCall.minutesUnit')}
{running && ( )}
{data && data.total > PAGE_SIZE && (
)}
{hasActiveSession && activeId ? ( ) : ( )} { setTasksModalOpen(false) setTasksTarget(null) }} onSaved={(saved) => { upsertItem(saved) void load() }} />
) }