Add roll-call Start/Stop UX to business and customer dashboards.
Owners/admins can run timed work days, manage tasks in a modal, and stop from the FAB or row control. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ac287e02cc
commit
c4e846564e
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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')] },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user