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>
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|