Add roll-call Start/Stop UX to business and customer dashboards.

Owners/admins can run timed work days, manage tasks in a modal, and stop from the FAB or row control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-07 13:21:07 +03:30
co-authored by Cursor
parent ac287e02cc
commit c4e846564e
30 changed files with 4063 additions and 83 deletions
@@ -100,8 +100,8 @@ export function BusinessProfilePage() {
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
const categoryOptions = useMemo(
() => flattenBusinessActivityCategories(activityCategories),
[activityCategories],
() => flattenBusinessActivityCategories(activityCategories, locale),
[activityCategories, locale],
)
const selectedLogoAspect =
LOGO_ASPECTS.find((option) => option.id === logoAspect)?.aspect ?? 1
@@ -0,0 +1,406 @@
.filtersPanel {
margin-bottom: 12px;
padding: 10px 14px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.filtersGrid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin: 0;
padding: 0;
border: none;
}
.filtersInputs {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
flex: 0 1 auto;
min-width: 0;
}
.field {
display: flex;
flex-direction: column;
flex: 0 0 148px;
width: 148px;
min-width: 148px;
max-width: 148px;
}
.field input {
width: 100%;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
min-height: var(--field-height);
box-sizing: border-box;
}
.field input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
}
.filterActions {
display: flex;
gap: 8px;
align-items: center;
}
.iconActionBtn {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.iconActionBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.iconActionBtnPrimary {
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
}
.iconActionBtnGhost {
background: rgba(var(--primary-dark-rgb) / 0.06);
border: 1px solid rgba(var(--primary-dark-rgb) / 0.18);
color: var(--primary);
}
.iconActionBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.summaryRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.summaryItem {
padding: 12px 14px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
display: flex;
flex-direction: column;
gap: 4px;
}
.summaryLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.summaryValue {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.tablePanel {
margin-top: 0;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow-x: auto;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.tableHeaderTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.errorBanner {
margin: 12px 16px 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.table {
width: 100%;
border-collapse: collapse;
}
.th,
.td {
padding: 10px 12px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
.th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.06);
}
.td {
font-size: 13px;
color: var(--text-primary);
}
.thActions,
.tdActions {
width: 140px;
text-align: end;
}
.activeRow td {
background: rgba(var(--primary-rgb) / 0.06);
}
.runningBadge {
display: inline-block;
margin-inline-start: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
border-radius: 999px;
vertical-align: middle;
}
.minutesValue {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.minutesUnit {
margin-inline-start: 4px;
font-size: 12px;
color: var(--text-muted);
}
.tasksCell {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.actionBtn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 32px;
padding: 0 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
transition: background 0.2s, color 0.2s;
white-space: nowrap;
}
.actionBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.14);
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stopIconBtn {
color: #ef4444;
}
.stopIconBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
color: #f87171;
}
.tasksToggle {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border: 1px solid rgba(var(--primary-rgb) / 0.22);
transition: background 0.2s, border-color 0.2s, transform 0.15s;
white-space: nowrap;
}
.tasksToggle:hover {
background: rgba(var(--primary-rgb) / 0.16);
border-color: rgba(var(--primary-rgb) / 0.35);
transform: translateY(-1px);
}
.addTaskBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.32);
transition: transform 0.15s, box-shadow 0.2s, opacity 0.2s;
flex-shrink: 0;
}
.addTaskBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-dark-rgb) / 0.4);
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.iconBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.iconBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.iconBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pagination {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid rgba(148, 163, 184, 0.16);
}
.startFab {
position: fixed;
inset-inline-end: 32px;
bottom: 32px;
z-index: 50;
min-width: 120px;
height: 52px;
padding: 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.startFab:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.startFab:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.stopFab {
background: linear-gradient(135deg, #f87171 0%, #ef4444 55%, #dc2626 100%);
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
}
.stopFab:hover:not(:disabled) {
box-shadow: 0 12px 28px rgba(239, 68, 68, 0.45);
}
@media (max-width: 768px) {
.summaryRow {
grid-template-columns: 1fr;
}
.startFab {
inset-inline-end: 20px;
bottom: 20px;
min-width: 108px;
height: 48px;
padding: 0 16px;
}
.thActions,
.tdActions {
width: 100px;
}
.field {
flex: 0 0 140px;
width: 140px;
min-width: 140px;
max-width: 140px;
}
}
+557
View File
@@ -0,0 +1,557 @@
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, string | number>,
) => 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<RollCallsListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [starting, setStarting] = useState(false)
const [stoppingId, setStoppingId] = useState<string | null>(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<RollCall | null>(null)
const [removingId, setRemovingId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(() => 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 <Navigate to="/" replace />
}
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<RollCall, 'id'>) {
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 (
<main className={pageStyles.content}>
<Breadcrumbs
items={[{ label: t('nav.home'), href: '/' }, { label: t('rollCall.title') }]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="ROLL CALL">{t('rollCall.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('rollCall.subtitle')}</p>
</div>
</div>
<div className={styles.filtersPanel}>
<form
className={styles.filtersGrid}
onSubmit={(e) => {
e.preventDefault()
applyFilters()
}}
>
<div className={styles.filtersInputs}>
<div className={styles.field}>
<input
type="date"
value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)}
aria-label={t('rollCall.filter.dateFrom')}
title={t('rollCall.filter.dateFrom')}
/>
</div>
<div className={styles.field}>
<input
type="date"
value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)}
aria-label={t('rollCall.filter.dateTo')}
title={t('rollCall.filter.dateTo')}
/>
</div>
</div>
<div className={styles.filterActions}>
<button
type="submit"
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
disabled={loading}
aria-label={t('rollCall.search')}
title={t('rollCall.search')}
>
<Search size={18} />
</button>
<button
type="button"
className={`${styles.iconActionBtn} ${styles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label={t('rollCall.clearFilters')}
title={t('rollCall.clearFilters')}
>
<RotateCcw size={18} />
</button>
</div>
</form>
</div>
<div className={styles.summaryRow}>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{t('rollCall.summary.days')}</span>
<span className={styles.summaryValue}>{summary.totalDays}</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{t('rollCall.summary.hours')}</span>
<span className={styles.summaryValue}>
{formatDuration(summary.totalWorkingMinutes, t)}
</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{t('rollCall.summary.tasks')}</span>
<span className={styles.summaryValue}>{summary.totalTasks}</span>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>{t('rollCall.listTitle')}</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<thead>
<tr>
<th className={styles.th}>{t('rollCall.col.date')}</th>
<th className={styles.th}>{t('rollCall.col.start')}</th>
<th className={styles.th}>{t('rollCall.col.minutes')}</th>
<th className={styles.th}>{t('rollCall.col.tasks')}</th>
<th className={`${styles.th} ${styles.thActions}`}>
{t('rollCall.col.actions')}
</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
{t('rollCall.loading')}
</td>
</tr>
)}
{!loading && data?.items.length === 0 && (
<EmptyTableRow colSpan={COLUMN_COUNT} message={t('rollCall.empty')} />
)}
{!loading &&
data?.items.map((item) => {
const running = activeId === item.id
const minutes = spentMinutes(item)
return (
<tr key={item.id} className={running ? styles.activeRow : undefined}>
<td className={styles.td}>
{formatDateLabel(item.workDate, locale)}
{running && (
<span className={styles.runningBadge}>{t('rollCall.running')}</span>
)}
</td>
<td className={styles.td}>{item.startTime}</td>
<td className={styles.td}>
<span className={styles.minutesValue}>{minutes}</span>
<span className={styles.minutesUnit}>
{t('rollCall.minutesUnit')}
</span>
</td>
<td className={styles.td}>
<div className={styles.tasksCell}>
<button
type="button"
className={styles.tasksToggle}
onClick={() => openTasks(item)}
aria-label={t('rollCall.col.tasks')}
title={t('rollCall.col.tasks')}
>
<ClipboardList size={16} />
<span>
{t('rollCall.tasksDone', {
count: item.taskCount.toLocaleString(
locale === 'fa' ? 'fa-IR' : 'en-US',
),
})}
</span>
</button>
<Tooltip label={t('rollCall.addTask')}>
<button
type="button"
className={styles.addTaskBtn}
onClick={() => openTasks(item)}
aria-label={t('rollCall.addTask')}
>
<Plus size={18} strokeWidth={2.5} />
</button>
</Tooltip>
</div>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
{running && (
<Tooltip label={t('rollCall.stop')}>
<button
type="button"
className={`${styles.iconBtn} ${styles.stopIconBtn}`}
onClick={() => void handleStop(item)}
disabled={stoppingId === item.id}
aria-label={t('rollCall.stop')}
>
<Square size={15} />
</button>
</Tooltip>
)}
<Tooltip label={t('rollCall.remove')}>
<button
type="button"
className={styles.iconBtn}
onClick={() => void handleRemove(item)}
disabled={removingId === item.id}
aria-label={t('rollCall.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{data && data.total > PAGE_SIZE && (
<div className={styles.pagination}>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
)}
</div>
</div>
{hasActiveSession && activeId ? (
<button
type="button"
className={`${styles.startFab} ${styles.stopFab}`}
onClick={() => void handleStop(activeItem ?? { id: activeId })}
disabled={stoppingId === activeId}
aria-label={t('rollCall.stop')}
title={t('rollCall.stop')}
>
<Square size={18} />
<span>{t('rollCall.stop')}</span>
</button>
) : (
<button
type="button"
className={styles.startFab}
onClick={() => void handleStart()}
disabled={starting}
aria-label={t('rollCall.start')}
title={t('rollCall.start')}
>
<Play size={20} />
<span>{t('rollCall.start')}</span>
</button>
)}
<RollCallTasksModal
open={tasksModalOpen}
rollCall={tasksTarget}
onClose={() => {
setTasksModalOpen(false)
setTasksTarget(null)
}}
onSaved={(saved) => {
upsertItem(saved)
void load()
}}
/>
</main>
)
}