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:
co-authored by
Cursor
parent
ac287e02cc
commit
c4e846564e
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user