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
+2
View File
@@ -24,6 +24,7 @@ import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage'
import { RollCallPage } from './pages/RollCallPage'
import { InvoicesPage } from './pages/InvoicesPage'
import { IssueInvoicePage } from './pages/IssueInvoicePage'
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
@@ -119,6 +120,7 @@ function App() {
<Route path="store/installments/edit/:id" element={<AddStoreInstallmentPage />} />
<Route path="store/settings" element={<StoreSettingsPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="roll-call" element={<RollCallPage />} />
<Route path="finance" element={<FinancePage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="invoices/new" element={<IssueInvoicePage />} />
@@ -64,19 +64,37 @@
}
.dropdown {
position: static;
margin-top: 4px;
z-index: auto;
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
isolation: isolate;
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
animation: menuIn 0.16s ease;
}
.dropdown::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
-webkit-backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
}
@keyframes menuIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.searchWrap {
@@ -110,6 +128,8 @@
}
.optionsList {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 6px;
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, Search } from 'lucide-react'
import styles from './MultiSelectDropdown.module.css'
@@ -6,6 +7,8 @@ export interface MultiSelectOption<T extends string | number = number> {
value: T
label: string
depth?: number
/** Extra text matched by search (e.g. EN+FA names, tags). */
keywords?: string
}
interface MultiSelectDropdownProps<T extends string | number = number> {
@@ -19,6 +22,13 @@ interface MultiSelectDropdownProps<T extends string | number = number> {
id?: string
}
type MenuPosition = {
top: number
left: number
width: number
maxHeight: number
}
export function MultiSelectDropdown<T extends string | number = number>({
options,
value,
@@ -32,8 +42,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [triggerLabel, setTriggerLabel] = useState(placeholder)
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -45,6 +57,29 @@ export function MultiSelectDropdown<T extends string | number = number>({
[options, value],
)
const updateMenuPosition = () => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const gap = 4
const viewportPad = 8
const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPad
const spaceAbove = rect.top - gap - viewportPad
const preferBelow = spaceBelow >= 180 || spaceBelow >= spaceAbove
const maxHeight = Math.max(160, Math.min(320, preferBelow ? spaceBelow : spaceAbove))
const top = preferBelow
? rect.bottom + gap
: Math.max(viewportPad, rect.top - gap - maxHeight)
setMenuPos({
top,
left: rect.left,
width: rect.width,
maxHeight,
})
}
useLayoutEffect(() => {
const trigger = triggerRef.current
const measure = measureRef.current
@@ -74,9 +109,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
const style = getComputedStyle(triggerEl)
measureEl.style.font = style.font
const padding =
parseFloat(style.paddingLeft) +
parseFloat(style.paddingRight)
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
const gap = parseFloat(style.columnGap || style.gap || '10')
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
const availableWidth = triggerEl.clientWidth - reservedWidth
@@ -103,9 +136,32 @@ export function MultiSelectDropdown<T extends string | number = number>({
const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!searchable || !query) return options
return options.filter((option) => option.label.toLowerCase().includes(query))
return options.filter((option) => {
const haystack = `${option.label} ${option.keywords ?? ''}`.toLowerCase()
return haystack.includes(query)
})
}, [options, searchQuery, searchable])
useLayoutEffect(() => {
if (!open) {
setMenuPos(null)
return
}
updateMenuPosition()
function onReposition() {
updateMenuPosition()
}
window.addEventListener('resize', onReposition)
window.addEventListener('scroll', onReposition, true)
return () => {
window.removeEventListener('resize', onReposition)
window.removeEventListener('scroll', onReposition, true)
}
}, [open])
useEffect(() => {
if (!open) {
setSearchQuery('')
@@ -113,9 +169,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
const target = e.target as Node
if (containerRef.current?.contains(target)) return
if (menuRef.current?.contains(target)) return
setOpen(false)
}
function handleEscape(e: KeyboardEvent) {
@@ -134,7 +191,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
if (open && searchable) {
searchInputRef.current?.focus()
}
}, [open, searchable])
}, [open, searchable, menuPos])
function toggleOption(optionValue: T) {
if (value.includes(optionValue)) {
@@ -144,6 +201,68 @@ export function MultiSelectDropdown<T extends string | number = number>({
onChange([...value, optionValue])
}
const menu =
open && menuPos
? createPortal(
<div
ref={menuRef}
className={styles.dropdown}
style={{
top: menuPos.top,
left: menuPos.left,
width: menuPos.width,
maxHeight: menuPos.maxHeight,
}}
role="presentation"
>
{searchable && (
<div className={styles.searchWrap}>
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
<input
ref={searchInputRef}
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? (
<li className={styles.empty}>
{options.length === 0 ? 'No options available.' : 'No matching options.'}
</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingInlineStart: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>,
document.body,
)
: null
return (
<div className={styles.wrapper} ref={containerRef}>
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
@@ -163,54 +282,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
<span className={styles.triggerText}>{triggerLabel}</span>
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</button>
{open && (
<div className={styles.dropdown}>
{searchable && (
<div className={styles.searchWrap}>
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
<input
ref={searchInputRef}
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? (
<li className={styles.empty}>
{options.length === 0 ? 'No options available.' : 'No matching options.'}
</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>
)}
{menu}
</div>
)
}
@@ -0,0 +1,176 @@
.modal {
max-width: 520px;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
.error {
margin-bottom: 12px;
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);
}
.timeRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tasksBlock {
margin-top: 4px;
margin-bottom: 16px;
}
.tasksHeader {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.tasksLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.addTaskRowBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px dashed rgba(var(--primary-rgb) / 0.4);
border-radius: var(--radius-sm);
transition: background 0.2s, border-color 0.2s, color 0.2s;
box-sizing: border-box;
}
.addTaskRowBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
border-color: rgba(var(--primary-rgb) / 0.55);
}
.taskList {
display: flex;
flex-direction: column;
gap: 8px;
}
.taskRow {
display: flex;
align-items: center;
gap: 8px;
}
.taskRow input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
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;
}
.taskRow input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
}
.removeTaskBtn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.removeTaskBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.removeTaskBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.cancelBtn,
.saveBtn {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
transition: background 0.2s, transform 0.15s;
}
.cancelBtn {
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
}
.saveBtn {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
}
.saveBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.cancelBtn:disabled,
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 480px) {
.timeRow {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,279 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createRollCall,
updateRollCall,
type RollCall,
type RollCallInput,
} from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallFormModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
function emptyTasks() {
return ['']
}
export function RollCallFormModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [workDate, setWorkDate] = useState('')
const [startTime, setStartTime] = useState('09:00')
const [endTime, setEndTime] = useState('17:00')
const [tasks, setTasks] = useState<string[]>(emptyTasks())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
if (rollCall) {
setWorkDate(rollCall.workDate)
setStartTime(rollCall.startTime)
setEndTime(rollCall.endTime)
setTasks(
rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: emptyTasks(),
)
} else {
setWorkDate(new Date().toISOString().slice(0, 10))
setStartTime('09:00')
setEndTime('17:00')
setTasks(emptyTasks())
}
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(rollCall?.id)
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const cleanedTasks = tasks.map((task) => task.trim()).filter(Boolean)
if (!workDate || !startTime || !endTime) {
setError(t('rollCall.error.incomplete'))
return
}
if (cleanedTasks.length === 0) {
setError(t('rollCall.error.tasksRequired'))
return
}
const input: RollCallInput = {
workDate,
startTime,
endTime,
tasks: cleanedTasks.map((title) => ({ title })),
}
setSaving(true)
try {
const result = rollCall
? await updateRollCall(rollCall.id, input)
: await createRollCall(input)
onSaved(result.rollCall)
showToast(
rollCall ? t('rollCall.toast.updated') : t('rollCall.toast.created'),
'success',
)
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-form-title" className={modalStyles.title}>
{isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={modalStyles.field}>
<label htmlFor="roll-call-date">{t('rollCall.field.date')}</label>
<input
id="roll-call-date"
type="date"
value={workDate}
onChange={(e) => setWorkDate(e.target.value)}
required
/>
</div>
<div className={styles.timeRow}>
<div className={modalStyles.field}>
<label htmlFor="roll-call-start">{t('rollCall.field.startTime')}</label>
<input
id="roll-call-start"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="roll-call-end">{t('rollCall.field.endTime')}</label>
<input
id="roll-call-end"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { updateRollCall, type RollCall } from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallTasksModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
export function RollCallTasksModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallTasksModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [tasks, setTasks] = useState<string[]>([''])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
setTasks(
rollCall && rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: [''],
)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted || !rollCall) return null
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!rollCall) return
setError('')
const cleaned = tasks.map((task) => task.trim()).filter(Boolean)
setSaving(true)
try {
const result = await updateRollCall(rollCall.id, {
tasks: cleaned.map((title) => ({ title })),
})
onSaved(result.rollCall)
showToast(t('rollCall.toast.tasksSaved'), 'success')
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-tasks-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-tasks-title" className={modalStyles.title}>
{t('rollCall.tasksModal.title')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.tasksModal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
+38 -11
View File
@@ -26,7 +26,9 @@ import type { BusinessMessageKey } from '../i18n/messages'
import {
BUSINESS_PROFILE_UPDATED_EVENT,
getActiveBusinessDomain,
getActiveBusinessId,
} from '../lib/businessContext'
import { canAccessRollCall } from '../utils/rollCallAccess'
import { isAbortError } from '../lib/api'
import { getBusinessProfile } from '../services/businessProfileService'
import { useTenantBranding } from '../context/TenantBrandingContext'
@@ -68,16 +70,19 @@ function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true
}
if (group.id === 'content') {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
if (group.id === 'content') {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
}
if (group.id === 'customers' && pathname.startsWith('/roll-call')) {
return true
}
return group.children.some(
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
)
@@ -165,7 +170,29 @@ export function Sidebar({ open = false }: SidebarProps) {
{ labelKey: 'nav.store.settings', to: '/store/settings' },
],
},
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
...(canAccessRollCall(user, getActiveBusinessId())
? [
{
type: 'group' as const,
id: 'customers',
icon: Users,
labelKey: 'nav.customers' as BusinessMessageKey,
basePath: '/customers',
children: [
{ labelKey: 'nav.customers.list' as BusinessMessageKey, to: '/customers' },
{ labelKey: 'nav.rollCall' as BusinessMessageKey, to: '/roll-call' },
],
},
]
: [
{
type: 'link' as const,
id: 'customers',
icon: Users,
labelKey: 'nav.customers' as BusinessMessageKey,
to: '/customers',
},
]),
{
type: 'group',
id: 'finance',
@@ -249,7 +276,7 @@ export function Sidebar({ open = false }: SidebarProps) {
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
}, [enabledModules])
}, [enabledModules, user])
useEffect(() => {
const controller = new AbortController()
+140
View File
@@ -35,6 +35,8 @@ const en = {
'nav.store.installments': 'Installments & Facilities',
'nav.store.settings': 'Settings',
'nav.customers': 'Users',
'nav.customers.list': 'Users',
'nav.rollCall': 'Roll Call',
'nav.customerProducts': 'Customer products',
'nav.invoices': 'Invoices',
'nav.invoices.list': 'Invoices',
@@ -212,6 +214,10 @@ const en = {
'home.card.customers.desc': 'View and manage your customers and their activity.',
'home.card.customers.link': 'View customers',
'home.card.customers.count': 'people',
'home.card.rollCall.title': 'Roll Call',
'home.card.rollCall.desc': 'Log working days, hours, and tasks you completed.',
'home.card.rollCall.link': 'View roll call',
'home.card.rollCall.count': 'days',
'home.card.settings.title': 'Settings',
'home.card.settings.desc': 'Configure your store preferences and system settings.',
'home.card.settings.link': 'View settings',
@@ -2519,6 +2525,69 @@ const en = {
'title.addInstallment': 'Add Installment Method',
'title.editInstallment': 'Edit Installment Method',
'title.customers': 'Users',
'title.rollCall': 'Roll Call',
'rollCall.title': 'Roll Call',
'rollCall.subtitle': 'Log your working days, hours, and tasks.',
'rollCall.listTitle': 'Working days',
'rollCall.add': 'Add working day',
'rollCall.start': 'Start',
'rollCall.stop': 'Stop',
'rollCall.running': 'Running',
'rollCall.edit': 'Edit',
'rollCall.remove': 'Remove',
'rollCall.save': 'Save',
'rollCall.saving': 'Saving...',
'rollCall.cancel': 'Cancel',
'rollCall.loading': 'Loading roll calls...',
'rollCall.empty': 'No working days logged yet. Press Start to begin.',
'rollCall.noTasks': 'No tasks yet.',
'rollCall.search': 'Search',
'rollCall.clearFilters': 'Clear filters',
'rollCall.filter.dateFrom': 'From date',
'rollCall.filter.dateTo': 'To date',
'rollCall.summary.days': 'Days',
'rollCall.summary.hours': 'Total working hours',
'rollCall.summary.tasks': 'Total tasks',
'rollCall.col.date': 'Day',
'rollCall.col.start': 'Start hour',
'rollCall.col.hours': 'Working hours',
'rollCall.col.duration': 'Duration',
'rollCall.col.minutes': 'Time spent',
'rollCall.minutesUnit': 'min',
'rollCall.col.tasks': 'Tasks',
'rollCall.col.actions': 'Actions',
'rollCall.duration.minutesOnly': '{minutes}m',
'rollCall.duration.hoursOnly': '{hours}h',
'rollCall.duration.hoursMinutes': '{hours}h {minutes}m',
'rollCall.modal.addTitle': 'Add working day',
'rollCall.modal.editTitle': 'Edit working day',
'rollCall.modal.subtitle': 'Enter the day, working hours, and tasks you completed.',
'rollCall.tasksModal.title': 'Tasks',
'rollCall.tasksModal.subtitle': 'Add or edit tasks for this working day.',
'rollCall.field.date': 'Day',
'rollCall.field.startTime': 'Start time',
'rollCall.field.endTime': 'End time',
'rollCall.field.tasks': 'Tasks',
'rollCall.field.taskPlaceholder': 'What did you do?',
'rollCall.field.taskN': 'Task {n}',
'rollCall.addTask': 'Add task',
'rollCall.tasksDone': '{count} tasks done',
'rollCall.removeTask': 'Remove task',
'rollCall.toast.created': 'Working day saved.',
'rollCall.toast.updated': 'Working day updated.',
'rollCall.toast.removed': 'Working day removed.',
'rollCall.toast.started': 'Work day started.',
'rollCall.toast.resumed': 'Work day resumed.',
'rollCall.toast.stopped': 'Work day stopped.',
'rollCall.toast.tasksSaved': 'Tasks saved.',
'rollCall.error.load': 'Unable to load roll calls.',
'rollCall.error.save': 'Unable to save working day.',
'rollCall.error.remove': 'Unable to remove working day.',
'rollCall.error.start': 'Unable to start work day.',
'rollCall.error.stop': 'Unable to stop work day.',
'rollCall.error.alreadyRunning': 'A work day is already running. Stop it first.',
'rollCall.error.incomplete': 'Please fill in date and working hours.',
'rollCall.error.tasksRequired': 'Add at least one task.',
'title.customerProducts': 'Customer products',
'title.content': 'Content',
'title.blog': 'Blog',
@@ -2959,6 +3028,8 @@ const fa: Record<MessageKey, string> = {
'nav.store.installments': 'اقساط و تسهیلات',
'nav.store.settings': 'تنظیمات',
'nav.customers': 'کاربران',
'nav.customers.list': 'کاربران',
'nav.rollCall': 'حضور و غیاب',
'nav.customerProducts': 'محصولات مشتریان',
'nav.invoices': 'فاکتورها',
'nav.invoices.list': 'فاکتورها',
@@ -3136,6 +3207,10 @@ const fa: Record<MessageKey, string> = {
'home.card.customers.desc': 'مشتریان و فعالیت آن‌ها را ببینید و مدیریت کنید.',
'home.card.customers.link': 'مشاهده مشتریان',
'home.card.customers.count': 'نفر',
'home.card.rollCall.title': 'حضور و غیاب',
'home.card.rollCall.desc': 'روزهای کاری، ساعت‌ها و کارهای انجام‌شده را ثبت کنید.',
'home.card.rollCall.link': 'مشاهده حضور و غیاب',
'home.card.rollCall.count': 'روز',
'home.card.settings.title': 'تنظیمات',
'home.card.settings.desc': 'ترجیحات فروشگاه و تنظیمات سیستم را پیکربندی کنید.',
'home.card.settings.link': 'مشاهده تنظیمات',
@@ -5441,6 +5516,70 @@ const fa: Record<MessageKey, string> = {
'title.addInstallment': 'افزودن روش اقساط',
'title.editInstallment': 'ویرایش روش اقساط',
'title.customers': 'کاربران',
'title.rollCall': 'حضور و غیاب',
'rollCall.title': 'حضور و غیاب',
'rollCall.subtitle': 'روزهای کاری، ساعت‌ها و کارهای انجام‌شده را ثبت کنید.',
'rollCall.listTitle': 'روزهای کاری',
'rollCall.add': 'افزودن روز کاری',
'rollCall.start': 'شروع',
'rollCall.stop': 'توقف',
'rollCall.running': 'در حال اجرا',
'rollCall.edit': 'ویرایش',
'rollCall.remove': 'حذف',
'rollCall.save': 'ذخیره',
'rollCall.saving': 'در حال ذخیره...',
'rollCall.cancel': 'انصراف',
'rollCall.loading': 'در حال بارگذاری حضور و غیاب...',
'rollCall.empty': 'هنوز روز کاری ثبت نشده است. برای شروع روی «شروع» بزنید.',
'rollCall.noTasks': 'هنوز کاری ثبت نشده است.',
'rollCall.search': 'جستجو',
'rollCall.clearFilters': 'پاک کردن فیلترها',
'rollCall.filter.dateFrom': 'از تاریخ',
'rollCall.filter.dateTo': 'تا تاریخ',
'rollCall.summary.days': 'تعداد روز',
'rollCall.summary.hours': 'جمع ساعات کاری',
'rollCall.summary.tasks': 'جمع کارها',
'rollCall.col.date': 'روز',
'rollCall.col.start': 'ساعت شروع',
'rollCall.col.hours': 'ساعات کاری',
'rollCall.col.duration': 'مدت',
'rollCall.col.minutes': 'زمان سپری‌شده',
'rollCall.minutesUnit': 'دقیقه',
'rollCall.col.tasks': 'کارها',
'rollCall.col.actions': 'عملیات',
'rollCall.duration.minutesOnly': '{minutes} دقیقه',
'rollCall.duration.hoursOnly': '{hours} ساعت',
'rollCall.duration.hoursMinutes': '{hours} ساعت و {minutes} دقیقه',
'rollCall.modal.addTitle': 'افزودن روز کاری',
'rollCall.modal.editTitle': 'ویرایش روز کاری',
'rollCall.modal.subtitle': 'روز، ساعت کاری و کارهای انجام‌شده را وارد کنید.',
'rollCall.tasksModal.title': 'کارها',
'rollCall.tasksModal.subtitle': 'کارهای این روز کاری را اضافه یا ویرایش کنید.',
'rollCall.field.date': 'روز',
'rollCall.field.startTime': 'ساعت شروع',
'rollCall.field.endTime': 'ساعت پایان',
'rollCall.field.tasks': 'کارها',
'rollCall.field.taskPlaceholder': 'چه کاری انجام دادید؟',
'rollCall.field.taskN': 'کار {n}',
'rollCall.addTask': 'افزودن کار',
'rollCall.tasksDone': '{count} کار انجام شده',
'rollCall.removeTask': 'حذف کار',
'rollCall.toast.created': 'روز کاری ذخیره شد.',
'rollCall.toast.updated': 'روز کاری به‌روزرسانی شد.',
'rollCall.toast.removed': 'روز کاری حذف شد.',
'rollCall.toast.started': 'روز کاری شروع شد.',
'rollCall.toast.resumed': 'روز کاری ادامه یافت.',
'rollCall.toast.stopped': 'روز کاری متوقف شد.',
'rollCall.toast.tasksSaved': 'کارها ذخیره شد.',
'rollCall.error.load': 'بارگذاری حضور و غیاب ممکن نشد.',
'rollCall.error.save': 'ذخیره روز کاری ممکن نشد.',
'rollCall.error.remove': 'حذف روز کاری ممکن نشد.',
'rollCall.error.start': 'شروع روز کاری ممکن نشد.',
'rollCall.error.stop': 'توقف روز کاری ممکن نشد.',
'rollCall.error.alreadyRunning': 'یک روز کاری در حال اجراست. ابتدا آن را متوقف کنید.',
'rollCall.error.incomplete': 'تاریخ و ساعات کاری را تکمیل کنید.',
'rollCall.error.tasksRequired': 'حداقل یک کار اضافه کنید.',
'title.customerProducts': 'محصولات مشتریان',
'title.content': 'مدیریت محتوا',
'title.blog': 'بلاگ',
@@ -5973,6 +6112,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/store/settings', labels: [t('title.store'), t('title.settings')] },
{ match: '/store', labels: [t('title.store')] },
{ match: '/customers', labels: [t('title.customers')] },
{ match: '/roll-call', labels: [t('title.rollCall')] },
{ match: '/finance', labels: [t('title.finance')] },
{ match: '/customer-products/new', labels: [t('title.customerProducts'), t('customerProducts.add')] },
{ match: /^\/customer-products\/[^/]+\/edit$/, labels: [t('title.customerProducts'), t('customerProducts.edit')] },
@@ -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>
)
}
@@ -4,10 +4,12 @@ export interface BusinessActivityCategory {
id: string
parentId: string | null
name: string
nameFa: string | null
slug: string
description: string | null
icon: string | null
sortOrder: number
tags: string[]
}
export async function listBusinessActivityCategories(signal?: AbortSignal) {
@@ -19,5 +21,7 @@ export async function listBusinessActivityCategories(signal?: AbortSignal) {
...item,
id: String(item.id),
parentId: item.parentId ? String(item.parentId) : null,
nameFa: item.nameFa ?? null,
tags: Array.isArray(item.tags) ? item.tags : [],
}))
}
@@ -0,0 +1,102 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface RollCallTask {
id: string
title: string
sortOrder: number
}
export interface RollCall {
id: string
businessId: string
userId: string
workDate: string
startTime: string
endTime: string
workingMinutes: number
taskCount: number
tasks: RollCallTask[]
createdAt: string
updatedAt: string
}
export interface RollCallSummary {
totalDays: number
totalWorkingMinutes: number
totalTasks: number
}
export interface RollCallsListResponse {
items: RollCall[]
total: number
page: number
pageSize: number
summary: RollCallSummary
}
export interface ListRollCallsParams {
dateFrom?: string
dateTo?: string
page?: number
pageSize?: number
}
export interface RollCallInput {
workDate: string
startTime: string
endTime: string
tasks?: { title: string }[]
}
export type RollCallPatch = Partial<RollCallInput>
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/roll-calls${suffix}`
}
export async function listRollCalls(
params: ListRollCallsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
const query = q.toString()
return apiRequest<RollCallsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function createRollCall(input: RollCallInput) {
return apiRequest<{ rollCall: RollCall; message: string }>(businessPath(), {
method: 'POST',
auth: true,
body: input,
})
}
export async function updateRollCall(rollCallId: string, input: RollCallPatch) {
return apiRequest<{ rollCall: RollCall; message: string }>(
businessPath(`/${rollCallId}`),
{
method: 'PATCH',
auth: true,
body: input,
},
)
}
export async function removeRollCall(rollCallId: string) {
return apiRequest<{ message: string }>(businessPath(`/${rollCallId}`), {
method: 'DELETE',
auth: true,
})
}
+21 -1
View File
@@ -1,8 +1,20 @@
import type { MultiSelectOption } from '../components/MultiSelectDropdown'
import type { BusinessActivityCategory } from '../services/businessActivityCategoryService'
/** Same platform taxonomy as super-admin + IranBay (`business_categories`). */
export function businessCategoryLabel(
category: Pick<BusinessActivityCategory, 'name' | 'nameFa'>,
locale: 'en' | 'fa' = 'en',
) {
if (locale === 'fa') {
return category.nameFa?.trim() || category.name
}
return category.name
}
export function flattenBusinessActivityCategories(
categories: BusinessActivityCategory[],
locale: 'en' | 'fa' = 'en',
): MultiSelectOption<string>[] {
const result: MultiSelectOption<string>[] = []
@@ -12,7 +24,15 @@ export function flattenBusinessActivityCategories(
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
for (const child of children) {
result.push({ value: child.id, label: child.name, depth })
const keywords = [child.name, child.nameFa ?? '', child.slug, ...(child.tags ?? [])]
.join(' ')
.trim()
result.push({
value: child.id,
label: businessCategoryLabel(child, locale),
depth,
keywords,
})
walk(child.id, depth + 1)
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { AuthUser } from '../types/auth'
function membershipAllowsRollCall(membership: {
isOwner: boolean
teamRole: string | null
permissions: string[]
}): boolean {
if (membership.isOwner) return true
if (membership.teamRole === 'admin' || membership.teamRole === 'business_owner') {
return true
}
return membership.permissions.includes('roll_calls.read')
}
export function canAccessRollCall(
user: AuthUser | null | undefined,
activeBusinessId: string | null,
): boolean {
if (!user) return false
if (user.isSuperAdmin || user.roles.includes('super_admin')) {
return true
}
if (activeBusinessId) {
const membership = user.businesses.find(
(business) => String(business.id) === String(activeBusinessId),
)
if (membership) return membershipAllowsRollCall(membership)
}
return user.businesses.some((membership) => membershipAllowsRollCall(membership))
}
+2
View File
@@ -17,6 +17,7 @@ import { FavoritesPage } from './pages/FavoritesPage'
import { MyProductsPage } from './pages/MyProductsPage'
import { MyProductDetailsPage } from './pages/MyProductDetailsPage'
import { AddMyProductPage } from './pages/AddMyProductPage'
import { RollCallPage } from './pages/RollCallPage'
import { LoginPage } from './pages/LoginPage'
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
@@ -69,6 +70,7 @@ function App() {
<Route path="favorites" element={<FavoritesPage />} />
<Route path="my-products" element={<MyProductsPage />} />
<Route path="my-products/:id" element={<MyProductDetailsPage />} />
<Route path="roll-call" element={<RollCallPage />} />
</Route>
</Route>
</Routes>
@@ -0,0 +1,176 @@
.modal {
max-width: 520px;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
.error {
margin-bottom: 12px;
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);
}
.timeRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tasksBlock {
margin-top: 4px;
margin-bottom: 16px;
}
.tasksHeader {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.tasksLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.addTaskRowBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px dashed rgba(var(--primary-rgb) / 0.4);
border-radius: var(--radius-sm);
transition: background 0.2s, border-color 0.2s, color 0.2s;
box-sizing: border-box;
}
.addTaskRowBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
border-color: rgba(var(--primary-rgb) / 0.55);
}
.taskList {
display: flex;
flex-direction: column;
gap: 8px;
}
.taskRow {
display: flex;
align-items: center;
gap: 8px;
}
.taskRow input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
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;
}
.taskRow input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
}
.removeTaskBtn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.removeTaskBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.removeTaskBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.cancelBtn,
.saveBtn {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
transition: background 0.2s, transform 0.15s;
}
.cancelBtn {
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
}
.saveBtn {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
}
.saveBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.cancelBtn:disabled,
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 480px) {
.timeRow {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,278 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createRollCall,
updateRollCall,
type RollCall,
type RollCallInput,
} from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallFormModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
function emptyTasks() {
return ['']
}
export function RollCallFormModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [workDate, setWorkDate] = useState('')
const [startTime, setStartTime] = useState('09:00')
const [endTime, setEndTime] = useState('17:00')
const [tasks, setTasks] = useState<string[]>(emptyTasks())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
if (rollCall) {
setWorkDate(rollCall.workDate)
setStartTime(rollCall.startTime)
setEndTime(rollCall.endTime)
setTasks(
rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: emptyTasks(),
)
} else {
setWorkDate(new Date().toISOString().slice(0, 10))
setStartTime('09:00')
setEndTime('17:00')
setTasks(emptyTasks())
}
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(rollCall?.id)
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const cleanedTasks = tasks.map((task) => task.trim()).filter(Boolean)
if (!workDate || !startTime || !endTime) {
setError(t('rollCall.error.incomplete'))
return
}
if (cleanedTasks.length === 0) {
setError(t('rollCall.error.tasksRequired'))
return
}
const input: RollCallInput = {
workDate,
startTime,
endTime,
tasks: cleanedTasks.map((title) => ({ title })),
}
setSaving(true)
try {
const result = rollCall
? await updateRollCall(rollCall.id, input)
: await createRollCall(input)
onSaved(result.rollCall)
showToast(
rollCall ? t('rollCall.toast.updated') : t('rollCall.toast.created'),
'success',
)
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-form-title" className={modalStyles.title}>
{isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={modalStyles.field}>
<label htmlFor="roll-call-date">{t('rollCall.field.date')}</label>
<input
id="roll-call-date"
type="date"
value={workDate}
onChange={(e) => setWorkDate(e.target.value)}
required
/>
</div>
<div className={styles.timeRow}>
<div className={modalStyles.field}>
<label htmlFor="roll-call-start">{t('rollCall.field.startTime')}</label>
<input
id="roll-call-start"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="roll-call-end">{t('rollCall.field.endTime')}</label>
<input
id="roll-call-end"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { updateRollCall, type RollCall } from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallTasksModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
export function RollCallTasksModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallTasksModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [tasks, setTasks] = useState<string[]>([''])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
setTasks(
rollCall && rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: [''],
)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted || !rollCall) return null
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!rollCall) return
setError('')
const cleaned = tasks.map((task) => task.trim()).filter(Boolean)
setSaving(true)
try {
const result = await updateRollCall(rollCall.id, {
tasks: cleaned.map((title) => ({ title })),
})
onSaved(result.rollCall)
showToast(t('rollCall.toast.tasksSaved'), 'success')
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-tasks-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-tasks-title" className={modalStyles.title}>
{t('rollCall.tasksModal.title')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.tasksModal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
+8 -2
View File
@@ -1,14 +1,15 @@
import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut, Globe } from 'lucide-react'
import { Home, User, MapPin, ShoppingBag, Heart, Package, ClipboardList, HelpCircle, LogOut, Globe } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { getActiveBusinessDomain, getActiveBusinessId } from '../lib/businessContext'
import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../services/websiteService'
import { hasBusinessModule } from '../utils/businessModules'
import { canAccessRollCall } from '../utils/rollCallAccess'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
@@ -30,6 +31,8 @@ export function Sidebar({ open = false }: SidebarProps) {
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
const activeBusinessId = getActiveBusinessId()
const showRollCall = canAccessRollCall(user, activeBusinessId)
const navItems = [
{ icon: Home, label: t('nav.home'), to: '/' },
@@ -40,6 +43,9 @@ export function Sidebar({ open = false }: SidebarProps) {
...(showMyProducts
? [{ icon: Package, label: t('nav.myProducts'), to: '/my-products' }]
: []),
...(showRollCall
? [{ icon: ClipboardList, label: t('nav.rollCall'), to: '/roll-call' }]
: []),
]
useEffect(() => {
+3 -1
View File
@@ -54,7 +54,9 @@ async function prepareCustomerContext(user: AuthUser) {
const tenant = await resolveTenantByDomain(domain)
const membership =
user.customerBusinesses.find((business) => business.id === tenant.id) ??
user.customerBusinesses.find(
(business) => String(business.id) === String(tenant.id),
) ??
user.customerBusinesses.find((business) => business.slug === tenant.slug) ??
user.customerBusinesses[0]
+137
View File
@@ -15,6 +15,7 @@ const en = {
'nav.orders': 'My Orders',
'nav.favorites': 'My Favorites',
'nav.myProducts': 'My Products',
'nav.rollCall': 'Roll Call',
'nav.help': 'Help Center',
'nav.goToWebsite': 'Go to website',
'nav.logout': 'Logout',
@@ -49,6 +50,10 @@ const en = {
'home.card.myProducts.desc': 'List and manage products you have submitted.',
'home.card.myProducts.link': 'View my products',
'home.card.myProducts.count': 'products',
'home.card.rollCall.title': 'Roll Call',
'home.card.rollCall.desc': 'Log working days, hours, and tasks you completed.',
'home.card.rollCall.link': 'View roll call',
'home.card.rollCall.count': 'days',
'profile.title': 'My Profile',
'profile.subtitle': 'Update your personal information and contact details.',
@@ -456,6 +461,69 @@ const en = {
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
'common.codeSent': 'Verification code sent to {phone}',
'rollCall.title': 'Roll Call',
'rollCall.subtitle': 'Log your working days, hours, and tasks.',
'rollCall.listTitle': 'Working days',
'rollCall.add': 'Add working day',
'rollCall.start': 'Start',
'rollCall.stop': 'Stop',
'rollCall.running': 'Running',
'rollCall.edit': 'Edit',
'rollCall.remove': 'Remove',
'rollCall.save': 'Save',
'rollCall.saving': 'Saving...',
'rollCall.cancel': 'Cancel',
'rollCall.loading': 'Loading roll calls...',
'rollCall.empty': 'No working days logged yet. Press Start to begin.',
'rollCall.noTasks': 'No tasks yet.',
'rollCall.search': 'Search',
'rollCall.clearFilters': 'Clear filters',
'rollCall.filter.dateFrom': 'From date',
'rollCall.filter.dateTo': 'To date',
'rollCall.summary.days': 'Days',
'rollCall.summary.hours': 'Total working hours',
'rollCall.summary.tasks': 'Total tasks',
'rollCall.col.date': 'Day',
'rollCall.col.start': 'Start hour',
'rollCall.col.hours': 'Working hours',
'rollCall.col.duration': 'Duration',
'rollCall.col.minutes': 'Time spent',
'rollCall.minutesUnit': 'min',
'rollCall.col.tasks': 'Tasks',
'rollCall.col.actions': 'Actions',
'rollCall.duration.minutesOnly': '{minutes}m',
'rollCall.duration.hoursOnly': '{hours}h',
'rollCall.duration.hoursMinutes': '{hours}h {minutes}m',
'rollCall.modal.addTitle': 'Add working day',
'rollCall.modal.editTitle': 'Edit working day',
'rollCall.modal.subtitle': 'Enter the day, working hours, and tasks you completed.',
'rollCall.tasksModal.title': 'Tasks',
'rollCall.tasksModal.subtitle': 'Add or edit tasks for this working day.',
'rollCall.field.date': 'Day',
'rollCall.field.startTime': 'Start time',
'rollCall.field.endTime': 'End time',
'rollCall.field.tasks': 'Tasks',
'rollCall.field.taskPlaceholder': 'What did you do?',
'rollCall.field.taskN': 'Task {n}',
'rollCall.addTask': 'Add task',
'rollCall.tasksDone': '{count} tasks done',
'rollCall.removeTask': 'Remove task',
'rollCall.toast.created': 'Working day saved.',
'rollCall.toast.updated': 'Working day updated.',
'rollCall.toast.removed': 'Working day removed.',
'rollCall.toast.started': 'Work day started.',
'rollCall.toast.resumed': 'Work day resumed.',
'rollCall.toast.stopped': 'Work day stopped.',
'rollCall.toast.tasksSaved': 'Tasks saved.',
'rollCall.error.load': 'Unable to load roll calls.',
'rollCall.error.save': 'Unable to save working day.',
'rollCall.error.remove': 'Unable to remove working day.',
'rollCall.error.start': 'Unable to start work day.',
'rollCall.error.stop': 'Unable to stop work day.',
'rollCall.error.alreadyRunning': 'A work day is already running. Stop it first.',
'rollCall.error.incomplete': 'Please fill in date and working hours.',
'rollCall.error.tasksRequired': 'Add at least one task.',
} as const
type MessageKey = keyof typeof en
@@ -475,6 +543,7 @@ const fa: Record<MessageKey, string> = {
'nav.orders': 'سفارش‌های من',
'nav.favorites': 'علاقه‌مندی‌ها',
'nav.myProducts': 'محصولات من',
'nav.rollCall': 'حضور و غیاب',
'nav.help': 'مرکز راهنما',
'nav.goToWebsite': 'رفتن به وبسایت',
'nav.logout': 'خروج',
@@ -509,6 +578,10 @@ const fa: Record<MessageKey, string> = {
'home.card.myProducts.desc': 'محصولاتی که ثبت کرده‌اید را ببینید و مدیریت کنید.',
'home.card.myProducts.link': 'مشاهده محصولات من',
'home.card.myProducts.count': 'محصول',
'home.card.rollCall.title': 'حضور و غیاب',
'home.card.rollCall.desc': 'روزهای کاری، ساعت‌ها و کارهای انجام‌شده را ثبت کنید.',
'home.card.rollCall.link': 'مشاهده حضور و غیاب',
'home.card.rollCall.count': 'روز',
'profile.title': 'پروفایل من',
'profile.subtitle': 'اطلاعات شخصی و راه‌های ارتباطی خود را به‌روزرسانی کنید.',
@@ -916,6 +989,69 @@ const fa: Record<MessageKey, string> = {
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
'rollCall.title': 'حضور و غیاب',
'rollCall.subtitle': 'روزهای کاری، ساعت‌ها و کارهای انجام‌شده را ثبت کنید.',
'rollCall.listTitle': 'روزهای کاری',
'rollCall.add': 'افزودن روز کاری',
'rollCall.start': 'شروع',
'rollCall.stop': 'توقف',
'rollCall.running': 'در حال اجرا',
'rollCall.edit': 'ویرایش',
'rollCall.remove': 'حذف',
'rollCall.save': 'ذخیره',
'rollCall.saving': 'در حال ذخیره...',
'rollCall.cancel': 'انصراف',
'rollCall.loading': 'در حال بارگذاری حضور و غیاب...',
'rollCall.empty': 'هنوز روز کاری ثبت نشده است. برای شروع روی «شروع» بزنید.',
'rollCall.noTasks': 'هنوز کاری ثبت نشده است.',
'rollCall.search': 'جستجو',
'rollCall.clearFilters': 'پاک کردن فیلترها',
'rollCall.filter.dateFrom': 'از تاریخ',
'rollCall.filter.dateTo': 'تا تاریخ',
'rollCall.summary.days': 'تعداد روز',
'rollCall.summary.hours': 'جمع ساعات کاری',
'rollCall.summary.tasks': 'جمع کارها',
'rollCall.col.date': 'روز',
'rollCall.col.start': 'ساعت شروع',
'rollCall.col.hours': 'ساعات کاری',
'rollCall.col.duration': 'مدت',
'rollCall.col.minutes': 'زمان سپری‌شده',
'rollCall.minutesUnit': 'دقیقه',
'rollCall.col.tasks': 'کارها',
'rollCall.col.actions': 'عملیات',
'rollCall.duration.minutesOnly': '{minutes} دقیقه',
'rollCall.duration.hoursOnly': '{hours} ساعت',
'rollCall.duration.hoursMinutes': '{hours} ساعت و {minutes} دقیقه',
'rollCall.modal.addTitle': 'افزودن روز کاری',
'rollCall.modal.editTitle': 'ویرایش روز کاری',
'rollCall.modal.subtitle': 'روز، ساعت کاری و کارهای انجام‌شده را وارد کنید.',
'rollCall.tasksModal.title': 'کارها',
'rollCall.tasksModal.subtitle': 'کارهای این روز کاری را اضافه یا ویرایش کنید.',
'rollCall.field.date': 'روز',
'rollCall.field.startTime': 'ساعت شروع',
'rollCall.field.endTime': 'ساعت پایان',
'rollCall.field.tasks': 'کارها',
'rollCall.field.taskPlaceholder': 'چه کاری انجام دادید؟',
'rollCall.field.taskN': 'کار {n}',
'rollCall.addTask': 'افزودن کار',
'rollCall.tasksDone': '{count} کار انجام شده',
'rollCall.removeTask': 'حذف کار',
'rollCall.toast.created': 'روز کاری ذخیره شد.',
'rollCall.toast.updated': 'روز کاری به‌روزرسانی شد.',
'rollCall.toast.removed': 'روز کاری حذف شد.',
'rollCall.toast.started': 'روز کاری شروع شد.',
'rollCall.toast.resumed': 'روز کاری ادامه یافت.',
'rollCall.toast.stopped': 'روز کاری متوقف شد.',
'rollCall.toast.tasksSaved': 'کارها ذخیره شد.',
'rollCall.error.load': 'بارگذاری حضور و غیاب ممکن نشد.',
'rollCall.error.save': 'ذخیره روز کاری ممکن نشد.',
'rollCall.error.remove': 'حذف روز کاری ممکن نشد.',
'rollCall.error.start': 'شروع روز کاری ممکن نشد.',
'rollCall.error.stop': 'توقف روز کاری ممکن نشد.',
'rollCall.error.alreadyRunning': 'یک روز کاری در حال اجراست. ابتدا آن را متوقف کنید.',
'rollCall.error.incomplete': 'تاریخ و ساعات کاری را تکمیل کنید.',
'rollCall.error.tasksRequired': 'حداقل یک کار اضافه کنید.',
}
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
@@ -959,6 +1095,7 @@ export function getCustomerRouteTitleRules(locale: DashboardLocale) {
{ match: /^\/my-products\/[^/]+\/edit$/, labels: [t('nav.myProducts'), t('myProducts.editTitle')] },
{ match: /^\/my-products\/[^/]+$/, labels: [t('nav.myProducts'), t('myProducts.detailsTitle')] },
{ match: '/my-products', labels: [t('nav.myProducts')] },
{ match: '/roll-call', labels: [t('nav.rollCall')] },
{ match: '/', labels: [t('nav.home')] },
]
}
+1
View File
@@ -20,5 +20,6 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: /^\/my-products\/[^/]+\/edit$/, labels: ['My Products', 'Edit product'] },
{ match: /^\/my-products\/[^/]+$/, labels: ['My Products', 'Product details'] },
{ match: '/my-products', labels: ['My Products'] },
{ match: '/roll-call', labels: ['Roll Call'] },
{ match: '/', labels: ['Home'] },
]
@@ -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;
}
}
+556
View File
@@ -0,0 +1,556 @@
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, useToast } 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 { 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>
)
}
@@ -0,0 +1,102 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface RollCallTask {
id: string
title: string
sortOrder: number
}
export interface RollCall {
id: string
businessId: string
userId: string
workDate: string
startTime: string
endTime: string
workingMinutes: number
taskCount: number
tasks: RollCallTask[]
createdAt: string
updatedAt: string
}
export interface RollCallSummary {
totalDays: number
totalWorkingMinutes: number
totalTasks: number
}
export interface RollCallsListResponse {
items: RollCall[]
total: number
page: number
pageSize: number
summary: RollCallSummary
}
export interface ListRollCallsParams {
dateFrom?: string
dateTo?: string
page?: number
pageSize?: number
}
export interface RollCallInput {
workDate: string
startTime: string
endTime: string
tasks?: { title: string }[]
}
export type RollCallPatch = Partial<RollCallInput>
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/roll-calls${suffix}`
}
export async function listRollCalls(
params: ListRollCallsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
const query = q.toString()
return apiRequest<RollCallsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function createRollCall(input: RollCallInput) {
return apiRequest<{ rollCall: RollCall; message: string }>(businessPath(), {
method: 'POST',
auth: true,
body: input,
})
}
export async function updateRollCall(rollCallId: string, input: RollCallPatch) {
return apiRequest<{ rollCall: RollCall; message: string }>(
businessPath(`/${rollCallId}`),
{
method: 'PATCH',
auth: true,
body: input,
},
)
}
export async function removeRollCall(rollCallId: string) {
return apiRequest<{ message: string }>(businessPath(`/${rollCallId}`), {
method: 'DELETE',
auth: true,
})
}
+51
View File
@@ -0,0 +1,51 @@
import type { AuthUser } from '../types/auth'
function membershipAllowsRollCall(membership: {
isOwner: boolean
teamRole: string | null
permissions: string[]
}): boolean {
if (membership.isOwner) return true
const role = membership.teamRole
if (role === 'admin' || role === 'business_owner' || role === 'owner') {
return true
}
return membership.permissions.includes('roll_calls.read')
}
function sameId(a: string | number | null | undefined, b: string | number | null | undefined) {
if (a == null || b == null) return false
return String(a) === String(b)
}
/**
* Show Roll Call in the customer dashboard when the user is staff
* (owner / admin) for the active tenant — same people who can open
* the business dashboard. No business module toggle is required.
*/
export function canAccessRollCall(
user: AuthUser | null | undefined,
activeBusinessId: string | null,
): boolean {
if (!user) return false
if (user.isSuperAdmin || user.roles.includes('super_admin')) {
return true
}
const staffMemberships = user.businesses.filter(membershipAllowsRollCall)
if (staffMemberships.length === 0) return false
if (activeBusinessId) {
const forTenant = staffMemberships.find((business) =>
sameId(business.id, activeBusinessId),
)
if (forTenant) return true
}
// Active id missing/stale: allow if any staff membership is also a
// customer membership (typical owner logged into customer dashboard).
return staffMemberships.some((membership) =>
user.customerBusinesses.some((customer) => sameId(customer.id, membership.id)),
)
}
@@ -6,6 +6,8 @@ export interface MultiSelectOption<T extends string | number = number> {
value: T
label: string
depth?: number
/** Extra text matched by search (e.g. EN+FA names, tags). */
keywords?: string
}
interface MultiSelectDropdownProps<T extends string | number = number> {
@@ -43,7 +45,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) return options
return options.filter((o) => o.label.toLowerCase().includes(query))
return options.filter((o) => {
const haystack = `${o.label} ${o.keywords ?? ''}`.toLowerCase()
return haystack.includes(query)
})
}, [options, searchQuery])
const triggerLabel = useMemo(() => {
+2
View File
@@ -2,10 +2,12 @@ export interface BusinessCategory {
id: number
parentId: number | null
name: string
nameFa: string | null
slug: string
description: string | null
icon: string | null
sortOrder: number
tags?: string[]
}
export interface BusinessCategoriesResponse {
+20 -1
View File
@@ -1,6 +1,17 @@
import type { BusinessCategory } from '../types/category'
import type { MultiSelectOption } from '../components/MultiSelectDropdown'
/** Match IranBay: "English / فارسی" when both exist. */
export function businessCategoryLabel(
category: Pick<BusinessCategory, 'name' | 'nameFa'>,
) {
const nameFa = category.nameFa?.trim()
if (nameFa && nameFa !== category.name) {
return `${category.name} / ${nameFa}`
}
return category.name
}
export function flattenBusinessCategories(
categories: BusinessCategory[],
): MultiSelectOption<number>[] {
@@ -12,7 +23,15 @@ export function flattenBusinessCategories(
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
for (const child of children) {
result.push({ value: child.id, label: child.name, depth })
const keywords = [child.name, child.nameFa ?? '', child.slug, ...(child.tags ?? [])]
.join(' ')
.trim()
result.push({
value: child.id,
label: businessCategoryLabel(child),
depth,
keywords,
})
walk(child.id, depth + 1)
}
}