From c4e846564eb28357ffea13af2ac7c7cdb835ff82 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 7 Oct 2026 13:21:07 +0330 Subject: [PATCH] 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 --- apps/business/src/App.tsx | 2 + .../components/MultiSelectDropdown.module.css | 36 +- .../src/components/MultiSelectDropdown.tsx | 184 ++++-- .../components/RollCallFormModal.module.css | 176 ++++++ .../src/components/RollCallFormModal.tsx | 279 +++++++++ .../src/components/RollCallTasksModal.tsx | 200 +++++++ apps/business/src/components/Sidebar.tsx | 49 +- apps/business/src/i18n/messages.ts | 140 +++++ .../src/pages/BusinessProfilePage.tsx | 4 +- .../src/pages/RollCallPage.module.css | 406 +++++++++++++ apps/business/src/pages/RollCallPage.tsx | 557 ++++++++++++++++++ .../businessActivityCategoryService.ts | 4 + apps/business/src/services/rollCallService.ts | 102 ++++ apps/business/src/utils/businessCategories.ts | 22 +- apps/business/src/utils/rollCallAccess.ts | 33 ++ apps/customer/src/App.tsx | 2 + .../components/RollCallFormModal.module.css | 176 ++++++ .../src/components/RollCallFormModal.tsx | 278 +++++++++ .../src/components/RollCallTasksModal.tsx | 199 +++++++ apps/customer/src/components/Sidebar.tsx | 10 +- apps/customer/src/context/AuthContext.tsx | 4 +- apps/customer/src/i18n/messages.ts | 137 +++++ apps/customer/src/lib/routeTitles.ts | 1 + .../src/pages/RollCallPage.module.css | 406 +++++++++++++ apps/customer/src/pages/RollCallPage.tsx | 556 +++++++++++++++++ apps/customer/src/services/rollCallService.ts | 102 ++++ apps/customer/src/utils/rollCallAccess.ts | 51 ++ .../src/components/MultiSelectDropdown.tsx | 7 +- apps/super-admin/src/types/category.ts | 2 + apps/super-admin/src/utils/categories.ts | 21 +- 30 files changed, 4063 insertions(+), 83 deletions(-) create mode 100644 apps/business/src/components/RollCallFormModal.module.css create mode 100644 apps/business/src/components/RollCallFormModal.tsx create mode 100644 apps/business/src/components/RollCallTasksModal.tsx create mode 100644 apps/business/src/pages/RollCallPage.module.css create mode 100644 apps/business/src/pages/RollCallPage.tsx create mode 100644 apps/business/src/services/rollCallService.ts create mode 100644 apps/business/src/utils/rollCallAccess.ts create mode 100644 apps/customer/src/components/RollCallFormModal.module.css create mode 100644 apps/customer/src/components/RollCallFormModal.tsx create mode 100644 apps/customer/src/components/RollCallTasksModal.tsx create mode 100644 apps/customer/src/pages/RollCallPage.module.css create mode 100644 apps/customer/src/pages/RollCallPage.tsx create mode 100644 apps/customer/src/services/rollCallService.ts create mode 100644 apps/customer/src/utils/rollCallAccess.ts diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 302a801..d902965 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/MultiSelectDropdown.module.css b/apps/business/src/components/MultiSelectDropdown.module.css index fb56fe8..9777f5e 100644 --- a/apps/business/src/components/MultiSelectDropdown.module.css +++ b/apps/business/src/components/MultiSelectDropdown.module.css @@ -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; diff --git a/apps/business/src/components/MultiSelectDropdown.tsx b/apps/business/src/components/MultiSelectDropdown.tsx index e61c90d..f4ca904 100644 --- a/apps/business/src/components/MultiSelectDropdown.tsx +++ b/apps/business/src/components/MultiSelectDropdown.tsx @@ -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 { value: T label: string depth?: number + /** Extra text matched by search (e.g. EN+FA names, tags). */ + keywords?: string } interface MultiSelectDropdownProps { @@ -19,6 +22,13 @@ interface MultiSelectDropdownProps { id?: string } +type MenuPosition = { + top: number + left: number + width: number + maxHeight: number +} + export function MultiSelectDropdown({ options, value, @@ -32,8 +42,10 @@ export function MultiSelectDropdown({ const [open, setOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [triggerLabel, setTriggerLabel] = useState(placeholder) + const [menuPos, setMenuPos] = useState(null) const containerRef = useRef(null) const triggerRef = useRef(null) + const menuRef = useRef(null) const measureRef = useRef(null) const searchInputRef = useRef(null) @@ -45,6 +57,29 @@ export function MultiSelectDropdown({ [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({ 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({ 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({ } 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({ 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({ onChange([...value, optionValue]) } + const menu = + open && menuPos + ? createPortal( +
+ {searchable && ( +
+
+ )} + +
    + {filteredOptions.length === 0 ? ( +
  • + {options.length === 0 ? 'No options available.' : 'No matching options.'} +
  • + ) : ( + filteredOptions.map((option) => { + const selected = value.includes(option.value) + return ( +
  • + +
  • + ) + }) + )} +
+
, + document.body, + ) + : null + return (
) } diff --git a/apps/business/src/components/RollCallFormModal.module.css b/apps/business/src/components/RollCallFormModal.module.css new file mode 100644 index 0000000..d5fa336 --- /dev/null +++ b/apps/business/src/components/RollCallFormModal.module.css @@ -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; + } +} diff --git a/apps/business/src/components/RollCallFormModal.tsx b/apps/business/src/components/RollCallFormModal.tsx new file mode 100644 index 0000000..114d877 --- /dev/null +++ b/apps/business/src/components/RollCallFormModal.tsx @@ -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(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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="roll-call-form-title" + lang={locale} + dir={dir} + > +
+
+

+ {isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')} +

+

{t('rollCall.modal.subtitle')}

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setWorkDate(e.target.value)} + required + /> +
+ +
+
+ + setStartTime(e.target.value)} + required + /> +
+
+ + setEndTime(e.target.value)} + required + /> +
+
+ +
+
+ {t('rollCall.field.tasks')} +
+ +
+ {tasks.map((task, index) => ( +
+ updateTask(index, e.target.value)} + placeholder={t('rollCall.field.taskPlaceholder')} + aria-label={t('rollCall.field.taskN', { n: index + 1 })} + /> + +
+ ))} + +
+
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/RollCallTasksModal.tsx b/apps/business/src/components/RollCallTasksModal.tsx new file mode 100644 index 0000000..e9f8f12 --- /dev/null +++ b/apps/business/src/components/RollCallTasksModal.tsx @@ -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(['']) + 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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="roll-call-tasks-title" + lang={locale} + dir={dir} + > +
+
+

+ {t('rollCall.tasksModal.title')} +

+

{t('rollCall.tasksModal.subtitle')}

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ {t('rollCall.field.tasks')} +
+ +
+ {tasks.map((task, index) => ( +
+ updateTask(index, e.target.value)} + placeholder={t('rollCall.field.taskPlaceholder')} + aria-label={t('rollCall.field.taskN', { n: index + 1 })} + /> + +
+ ))} + +
+
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index bea9c98..071ba29 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -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() diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index ff83d0a..42d28a3 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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 = { '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')] }, diff --git a/apps/business/src/pages/BusinessProfilePage.tsx b/apps/business/src/pages/BusinessProfilePage.tsx index c822f51..91c7821 100644 --- a/apps/business/src/pages/BusinessProfilePage.tsx +++ b/apps/business/src/pages/BusinessProfilePage.tsx @@ -100,8 +100,8 @@ export function BusinessProfilePage() { const [socialMedia, setSocialMedia] = useState(EMPTY_SOCIAL) const categoryOptions = useMemo( - () => flattenBusinessActivityCategories(activityCategories), - [activityCategories], + () => flattenBusinessActivityCategories(activityCategories, locale), + [activityCategories, locale], ) const selectedLogoAspect = LOGO_ASPECTS.find((option) => option.id === logoAspect)?.aspect ?? 1 diff --git a/apps/business/src/pages/RollCallPage.module.css b/apps/business/src/pages/RollCallPage.module.css new file mode 100644 index 0000000..eb38b7c --- /dev/null +++ b/apps/business/src/pages/RollCallPage.module.css @@ -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; + } +} diff --git a/apps/business/src/pages/RollCallPage.tsx b/apps/business/src/pages/RollCallPage.tsx new file mode 100644 index 0000000..ddcf3ed --- /dev/null +++ b/apps/business/src/pages/RollCallPage.tsx @@ -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, +) { + 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(null) + const [loading, setLoading] = useState(false) + const [starting, setStarting] = useState(false) + const [stoppingId, setStoppingId] = useState(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(null) + const [removingId, setRemovingId] = useState(null) + const [activeId, setActiveId] = useState(() => 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 + } + + 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) { + 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 ( +
+ + +
+
+ {t('rollCall.title')} +

{t('rollCall.subtitle')}

+
+
+ +
+
{ + e.preventDefault() + applyFilters() + }} + > +
+
+ setDraftDateFrom(e.target.value)} + aria-label={t('rollCall.filter.dateFrom')} + title={t('rollCall.filter.dateFrom')} + /> +
+
+ setDraftDateTo(e.target.value)} + aria-label={t('rollCall.filter.dateTo')} + title={t('rollCall.filter.dateTo')} + /> +
+
+
+ + +
+
+
+ +
+
+ {t('rollCall.summary.days')} + {summary.totalDays} +
+
+ {t('rollCall.summary.hours')} + + {formatDuration(summary.totalWorkingMinutes, t)} + +
+
+ {t('rollCall.summary.tasks')} + {summary.totalTasks} +
+
+ +
+
+
+
{t('rollCall.listTitle')}
+
+ + {error &&
{error}
} + + + + + + + + + + + + + {loading && ( + + + + )} + + {!loading && data?.items.length === 0 && ( + + )} + + {!loading && + data?.items.map((item) => { + const running = activeId === item.id + const minutes = spentMinutes(item) + return ( + + + + + + + + ) + })} + +
{t('rollCall.col.date')}{t('rollCall.col.start')}{t('rollCall.col.minutes')}{t('rollCall.col.tasks')} + {t('rollCall.col.actions')} +
+ {t('rollCall.loading')} +
+ {formatDateLabel(item.workDate, locale)} + {running && ( + {t('rollCall.running')} + )} + {item.startTime} + {minutes} + + {t('rollCall.minutesUnit')} + + +
+ + + + +
+
+
+ {running && ( + + + + )} + + + +
+
+ + {data && data.total > PAGE_SIZE && ( +
+ +
+ )} +
+
+ + {hasActiveSession && activeId ? ( + + ) : ( + + )} + + { + setTasksModalOpen(false) + setTasksTarget(null) + }} + onSaved={(saved) => { + upsertItem(saved) + void load() + }} + /> +
+ ) +} diff --git a/apps/business/src/services/businessActivityCategoryService.ts b/apps/business/src/services/businessActivityCategoryService.ts index 6b806db..e66e2c2 100644 --- a/apps/business/src/services/businessActivityCategoryService.ts +++ b/apps/business/src/services/businessActivityCategoryService.ts @@ -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 : [], })) } diff --git a/apps/business/src/services/rollCallService.ts b/apps/business/src/services/rollCallService.ts new file mode 100644 index 0000000..6f5ea82 --- /dev/null +++ b/apps/business/src/services/rollCallService.ts @@ -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 + +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( + `${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, + }) +} diff --git a/apps/business/src/utils/businessCategories.ts b/apps/business/src/utils/businessCategories.ts index bd566c4..a5d24e5 100644 --- a/apps/business/src/utils/businessCategories.ts +++ b/apps/business/src/utils/businessCategories.ts @@ -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, + 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[] { const result: MultiSelectOption[] = [] @@ -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) } } diff --git a/apps/business/src/utils/rollCallAccess.ts b/apps/business/src/utils/rollCallAccess.ts new file mode 100644 index 0000000..9a755dd --- /dev/null +++ b/apps/business/src/utils/rollCallAccess.ts @@ -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)) +} diff --git a/apps/customer/src/App.tsx b/apps/customer/src/App.tsx index 37dbf70..ca4e8fa 100644 --- a/apps/customer/src/App.tsx +++ b/apps/customer/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/apps/customer/src/components/RollCallFormModal.module.css b/apps/customer/src/components/RollCallFormModal.module.css new file mode 100644 index 0000000..d5fa336 --- /dev/null +++ b/apps/customer/src/components/RollCallFormModal.module.css @@ -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; + } +} diff --git a/apps/customer/src/components/RollCallFormModal.tsx b/apps/customer/src/components/RollCallFormModal.tsx new file mode 100644 index 0000000..d37816f --- /dev/null +++ b/apps/customer/src/components/RollCallFormModal.tsx @@ -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(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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="roll-call-form-title" + lang={locale} + dir={dir} + > +
+
+

+ {isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')} +

+

{t('rollCall.modal.subtitle')}

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setWorkDate(e.target.value)} + required + /> +
+ +
+
+ + setStartTime(e.target.value)} + required + /> +
+
+ + setEndTime(e.target.value)} + required + /> +
+
+ +
+
+ {t('rollCall.field.tasks')} +
+ +
+ {tasks.map((task, index) => ( +
+ updateTask(index, e.target.value)} + placeholder={t('rollCall.field.taskPlaceholder')} + aria-label={t('rollCall.field.taskN', { n: index + 1 })} + /> + +
+ ))} + +
+
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/customer/src/components/RollCallTasksModal.tsx b/apps/customer/src/components/RollCallTasksModal.tsx new file mode 100644 index 0000000..aa2ac60 --- /dev/null +++ b/apps/customer/src/components/RollCallTasksModal.tsx @@ -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(['']) + 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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="roll-call-tasks-title" + lang={locale} + dir={dir} + > +
+
+

+ {t('rollCall.tasksModal.title')} +

+

{t('rollCall.tasksModal.subtitle')}

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ {t('rollCall.field.tasks')} +
+ +
+ {tasks.map((task, index) => ( +
+ updateTask(index, e.target.value)} + placeholder={t('rollCall.field.taskPlaceholder')} + aria-label={t('rollCall.field.taskN', { n: index + 1 })} + /> + +
+ ))} + +
+
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/customer/src/components/Sidebar.tsx b/apps/customer/src/components/Sidebar.tsx index d20c912..fa8cb4c 100644 --- a/apps/customer/src/components/Sidebar.tsx +++ b/apps/customer/src/components/Sidebar.tsx @@ -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(() => { diff --git a/apps/customer/src/context/AuthContext.tsx b/apps/customer/src/context/AuthContext.tsx index 7381c82..4b00284 100644 --- a/apps/customer/src/context/AuthContext.tsx +++ b/apps/customer/src/context/AuthContext.tsx @@ -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] diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 5488b09..79ce154 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -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 = { 'nav.orders': 'سفارش‌های من', 'nav.favorites': 'علاقه‌مندی‌ها', 'nav.myProducts': 'محصولات من', + 'nav.rollCall': 'حضور و غیاب', 'nav.help': 'مرکز راهنما', 'nav.goToWebsite': 'رفتن به وبسایت', 'nav.logout': 'خروج', @@ -509,6 +578,10 @@ const fa: Record = { '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 = { '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> = { @@ -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')] }, ] } diff --git a/apps/customer/src/lib/routeTitles.ts b/apps/customer/src/lib/routeTitles.ts index 8629f58..ef78170 100644 --- a/apps/customer/src/lib/routeTitles.ts +++ b/apps/customer/src/lib/routeTitles.ts @@ -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'] }, ] diff --git a/apps/customer/src/pages/RollCallPage.module.css b/apps/customer/src/pages/RollCallPage.module.css new file mode 100644 index 0000000..eb38b7c --- /dev/null +++ b/apps/customer/src/pages/RollCallPage.module.css @@ -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; + } +} diff --git a/apps/customer/src/pages/RollCallPage.tsx b/apps/customer/src/pages/RollCallPage.tsx new file mode 100644 index 0000000..28efef3 --- /dev/null +++ b/apps/customer/src/pages/RollCallPage.tsx @@ -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, +) { + 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(null) + const [loading, setLoading] = useState(false) + const [starting, setStarting] = useState(false) + const [stoppingId, setStoppingId] = useState(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(null) + const [removingId, setRemovingId] = useState(null) + const [activeId, setActiveId] = useState(() => 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 + } + + 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) { + 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 ( +
+ + +
+
+ {t('rollCall.title')} +

{t('rollCall.subtitle')}

+
+
+ +
+
{ + e.preventDefault() + applyFilters() + }} + > +
+
+ setDraftDateFrom(e.target.value)} + aria-label={t('rollCall.filter.dateFrom')} + title={t('rollCall.filter.dateFrom')} + /> +
+
+ setDraftDateTo(e.target.value)} + aria-label={t('rollCall.filter.dateTo')} + title={t('rollCall.filter.dateTo')} + /> +
+
+
+ + +
+
+
+ +
+
+ {t('rollCall.summary.days')} + {summary.totalDays} +
+
+ {t('rollCall.summary.hours')} + + {formatDuration(summary.totalWorkingMinutes, t)} + +
+
+ {t('rollCall.summary.tasks')} + {summary.totalTasks} +
+
+ +
+
+
+
{t('rollCall.listTitle')}
+
+ + {error &&
{error}
} + + + + + + + + + + + + + {loading && ( + + + + )} + + {!loading && data?.items.length === 0 && ( + + )} + + {!loading && + data?.items.map((item) => { + const running = activeId === item.id + const minutes = spentMinutes(item) + return ( + + + + + + + + ) + })} + +
{t('rollCall.col.date')}{t('rollCall.col.start')}{t('rollCall.col.minutes')}{t('rollCall.col.tasks')} + {t('rollCall.col.actions')} +
+ {t('rollCall.loading')} +
+ {formatDateLabel(item.workDate, locale)} + {running && ( + {t('rollCall.running')} + )} + {item.startTime} + {minutes} + + {t('rollCall.minutesUnit')} + + +
+ + + + +
+
+
+ {running && ( + + + + )} + + + +
+
+ + {data && data.total > PAGE_SIZE && ( +
+ +
+ )} +
+
+ + {hasActiveSession && activeId ? ( + + ) : ( + + )} + + { + setTasksModalOpen(false) + setTasksTarget(null) + }} + onSaved={(saved) => { + upsertItem(saved) + void load() + }} + /> +
+ ) +} diff --git a/apps/customer/src/services/rollCallService.ts b/apps/customer/src/services/rollCallService.ts new file mode 100644 index 0000000..6f5ea82 --- /dev/null +++ b/apps/customer/src/services/rollCallService.ts @@ -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 + +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( + `${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, + }) +} diff --git a/apps/customer/src/utils/rollCallAccess.ts b/apps/customer/src/utils/rollCallAccess.ts new file mode 100644 index 0000000..e018f41 --- /dev/null +++ b/apps/customer/src/utils/rollCallAccess.ts @@ -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)), + ) +} diff --git a/apps/super-admin/src/components/MultiSelectDropdown.tsx b/apps/super-admin/src/components/MultiSelectDropdown.tsx index 4f03b13..70198a4 100644 --- a/apps/super-admin/src/components/MultiSelectDropdown.tsx +++ b/apps/super-admin/src/components/MultiSelectDropdown.tsx @@ -6,6 +6,8 @@ export interface MultiSelectOption { value: T label: string depth?: number + /** Extra text matched by search (e.g. EN+FA names, tags). */ + keywords?: string } interface MultiSelectDropdownProps { @@ -43,7 +45,10 @@ export function MultiSelectDropdown({ 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(() => { diff --git a/apps/super-admin/src/types/category.ts b/apps/super-admin/src/types/category.ts index 60b5375..6d448e2 100644 --- a/apps/super-admin/src/types/category.ts +++ b/apps/super-admin/src/types/category.ts @@ -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 { diff --git a/apps/super-admin/src/utils/categories.ts b/apps/super-admin/src/utils/categories.ts index 068abb9..4cf997d 100644 --- a/apps/super-admin/src/utils/categories.ts +++ b/apps/super-admin/src/utils/categories.ts @@ -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, +) { + const nameFa = category.nameFa?.trim() + if (nameFa && nameFa !== category.name) { + return `${category.name} / ${nameFa}` + } + return category.name +} + export function flattenBusinessCategories( categories: BusinessCategory[], ): MultiSelectOption[] { @@ -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) } }