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:
co-authored by
Cursor
parent
89269dff04
commit
d5aca8bea7
@@ -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(/ /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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user