Add business workshop management UI with Jalali scheduling and shared dashboard polish.

Includes workshop list/edit/details, registrations modal, category tree, shared EmptyState/RichTextEditor/toasts, and fixes for the Jalali date picker and thumbnail field layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 12:22:27 +03:30
co-authored by Cursor
parent 89269dff04
commit d5aca8bea7
76 changed files with 4719 additions and 974 deletions
@@ -0,0 +1,6 @@
export {
EmptyState,
EmptyStateEmphasis,
EmptyTableRow,
type EmptyStateProps,
} from '@meshkee/dashboard-ui'
@@ -1,83 +0,0 @@
.wrapper {
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.wrapper:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(248, 250, 252, 0.8);
}
.toolbar button {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.toolbar button:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.divider {
width: 1px;
height: 16px;
background: rgba(148, 163, 184, 0.3);
margin: 0 4px;
}
.editor {
min-height: 88px;
max-height: 160px;
overflow: auto;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.45;
color: var(--text-primary);
outline: none;
}
.editor:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.editor ul,
.editor ol {
padding-left: 1.4em;
margin: 0.35em 0;
}
.editor p {
margin: 0 0 0.35em;
}
.editor p:last-child {
margin-bottom: 0;
}
.editor blockquote {
margin: 0.35em 0;
margin-inline-start: 1.4em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
@@ -1,177 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
List,
ListOrdered,
Underline,
} from 'lucide-react'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
/** Compact editor height for short fields like invoice top text. */
editorMinHeight?: number
}
export function RichTextEditor({
value,
onChange,
placeholder = '',
editorMinHeight = 88,
}: RichTextEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
}
}, [value])
function exec(command: string) {
editorRef.current?.focus()
document.execCommand(command)
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
return (
<div className={styles.wrapper}>
<div className={styles.toolbar}>
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={14} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={14} />
</button>
<button
type="button"
onClick={() => exec('underline')}
title="Underline"
aria-label="Underline"
>
<Underline size={14} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => exec('insertUnorderedList')}
title="Bullet list"
aria-label="Bullet list"
>
<List size={14} />
</button>
<button
type="button"
onClick={() => exec('insertOrderedList')}
title="Numbered list"
aria-label="Numbered list"
>
<ListOrdered size={14} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={14} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={14} />
</button>
</div>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
</div>
)
}
/** True when HTML is empty or only whitespace / empty tags. */
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export {
RichTextEditor,
isEmptyRichText,
richTextToPlain,
type RichTextEditorProps,
} from '@meshkee/dashboard-ui'
@@ -1,12 +1,11 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
/* Above Modal overlay (z-index 300) so success/error feedback is visible while dialogs are open */
z-index: 400;
left: 24px;
z-index: var(--toast-z-index, 11000);
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: flex-start;
gap: 8px;
pointer-events: none;
}
@@ -20,12 +19,12 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
}
.toastIn {
+15 -11
View File
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import styles from '../components/Toast.module.css'
export type ToastVariant = 'success' | 'error' | 'info'
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={value}>
{children}
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>
{createPortal(
<div className={styles.container} aria-live="polite" aria-atomic="true">
{toast && (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
role="status"
>
{toast.message}
</div>
)}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}
+3
View File
@@ -36,6 +36,9 @@
--blur-glass: 28px;
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
--modal-overlay-blur: 16px;
--toast-z-index: 11000;
--toast-bg: rgba(255, 255, 255, 0.94);
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
--select-arrow-size: 16px;
--select-arrow-offset: 12px;
--select-padding-end: 2.5rem;
+15 -16
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EmptyTableRow } from '../components/EmptyState'
import { Modal } from '../components/Modal'
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
@@ -236,14 +237,13 @@ export function SettingsPage() {
</thead>
<tbody>
{!loading && invoiceTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={5}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>No invoice templates yet. Add one to speed up issuing invoices.</span>
</div>
</td>
</tr>
<EmptyTableRow
colSpan={5}
icon={SettingsIcon}
message="No invoice templates yet. Add one to speed up issuing invoices."
actionLabel="Add template"
actionTo="/settings/invoice-templates/new"
/>
) : null}
{invoiceTemplates.map((template) => (
<tr key={template.id}>
@@ -330,14 +330,13 @@ export function SettingsPage() {
</thead>
<tbody>
{!loading && itemTemplates.length === 0 ? (
<tr>
<td className={tableStyles.td} colSpan={6}>
<div className={styles.emptyState}>
<SettingsIcon size={20} />
<span>No predefined items yet.</span>
</div>
</td>
</tr>
<EmptyTableRow
colSpan={6}
icon={SettingsIcon}
message="No predefined items yet. Add one to reuse when building invoices."
actionLabel="Add item"
onAction={openCreateItem}
/>
) : null}
{itemTemplates.map((template) => (
<tr key={template.id}>
@@ -7,6 +7,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'warehouse',
'videos',
'instructions',
'workshops',
'finance',
] as const
@@ -35,6 +36,7 @@ export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
warehouse: 'Warehouse',
videos: 'Videos',
instructions: 'Instructions',
workshops: 'Workshops',
finance: 'Finance',
}
@@ -68,11 +70,10 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
/**
* Existing tenants without saved modules keep every business-dashboard module
* enabled. Customer modules stay opt-in.
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops')
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',