From 0aab7b5529c0a975cf30cda70294c194467e2582 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 15:28:41 +0330 Subject: [PATCH] Add Super Admin AI prompt templates and business assignment UI. Templates library plus per-business prompt list with edit, copy public link, and remove. Co-authored-by: Cursor --- apps/super-admin/src/App.tsx | 2 + .../BusinessActionsOverflowMenu.tsx | 15 + apps/super-admin/src/components/Sidebar.tsx | 3 +- .../src/components/StepDatePicker.module.css | 8 +- .../src/components/StepDatePicker.tsx | 301 +++++++++------- .../src/pages/BusinessesPage.module.css | 123 +++++++ apps/super-admin/src/pages/BusinessesPage.tsx | 338 ++++++++++++++++++ apps/super-admin/src/pages/HomePage.tsx | 9 +- .../src/pages/TemplatesPage.module.css | 126 +++++++ apps/super-admin/src/pages/TemplatesPage.tsx | 270 ++++++++++++++ .../src/services/aiPromptService.ts | 93 +++++ apps/super-admin/src/types/aiPrompt.ts | 52 +++ docs/PROJECT_CONTEXT.md | 1 + 13 files changed, 1209 insertions(+), 132 deletions(-) create mode 100644 apps/super-admin/src/pages/TemplatesPage.module.css create mode 100644 apps/super-admin/src/pages/TemplatesPage.tsx create mode 100644 apps/super-admin/src/services/aiPromptService.ts create mode 100644 apps/super-admin/src/types/aiPrompt.ts diff --git a/apps/super-admin/src/App.tsx b/apps/super-admin/src/App.tsx index 42a98e0..746bb61 100644 --- a/apps/super-admin/src/App.tsx +++ b/apps/super-admin/src/App.tsx @@ -16,6 +16,7 @@ import { InvoiceTemplateEditorPage } from './pages/InvoiceTemplateEditorPage' import { IssueInvoicePage } from './pages/IssueInvoicePage' import { PublicInvoicePage } from './pages/PublicInvoicePage' import { ProfilePage } from './pages/ProfilePage' +import { TemplatesPage } from './pages/TemplatesPage' import { LoginPage } from './pages/LoginPage' function ProtectedPages() { @@ -59,6 +60,7 @@ function App() { /> } /> } /> + } /> } /> void onAllUsers: () => void onWipeData: () => void onRemove: () => void @@ -46,6 +48,7 @@ function openHost(host: string) { export function BusinessActionsOverflowMenu({ businessName, host, + onAiPrompts, onAllUsers, onWipeData, onRemove, @@ -194,6 +197,18 @@ export function BusinessActionsOverflowMenu({ ))} {linkItems.length > 0 &&
} + + ) : ( + + )} +
+ + + + Day + +
+
+ + {step === 'year' ? ( +
+ {years.map((year) => ( + + ))} +
+ ) : null} + + {step === 'month' ? ( +
+ {MONTHS.map((label, index) => { + const month = index + 1 + return ( + + ) + })} +
+ ) : null} + + {step === 'day' ? ( +
+
+ {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => ( + + {d} + + ))} +
+
+ {Array.from({ length: padStart }, (_, i) => ( + + ))} + {Array.from({ length: dayCount }, (_, i) => { + const day = i + 1 + const isSelected = + selected?.year === draftYear && + selected?.month === draftMonth && + selected?.day === day + const isToday = + draftYear === today.year && + draftMonth === today.month && + day === today.day + return ( + + ) + })} +
+
+ ) : null} + , + document.body, + ) + : null + return (
- - {open ? ( -
-
- {step !== 'year' ? ( - - ) : ( - - )} -
- - - - Day - -
-
- - {step === 'year' ? ( -
- {years.map((year) => ( - - ))} -
- ) : null} - - {step === 'month' ? ( -
- {MONTHS.map((label, index) => { - const month = index + 1 - return ( - - ) - })} -
- ) : null} - - {step === 'day' ? ( -
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => ( - - {d} - - ))} -
-
- {Array.from({ length: padStart }, (_, i) => ( - - ))} - {Array.from({ length: dayCount }, (_, i) => { - const day = i + 1 - const isSelected = - selected?.year === draftYear && - selected?.month === draftMonth && - selected?.day === day - const isToday = - draftYear === today.year && - draftMonth === today.month && - day === today.day - return ( - - ) - })} -
-
- ) : null} -
- ) : null} + {panel}
) } diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css index bc1f701..3a93e4e 100644 --- a/apps/super-admin/src/pages/BusinessesPage.module.css +++ b/apps/super-admin/src/pages/BusinessesPage.module.css @@ -608,6 +608,129 @@ font-size: 12px; } +.promptsListHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.promptsListTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.promptsRowActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.promptsTextarea { + width: 100%; + min-height: 200px; + padding: var(--field-padding-y) var(--field-padding-x); + font-size: var(--field-font-size); + font-family: var(--font-ui); + line-height: 1.5; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + outline: none; + resize: vertical; + box-sizing: border-box; +} + +.promptsTextarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.promptsEmpty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 28px 16px; + border: 1px dashed rgba(148, 163, 184, 0.45); + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-align: center; +} + +.promptsEmpty svg { + color: var(--primary); +} + +.promptsEmpty p { + margin: 0; + font-size: 13px; +} + +.promptsList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.promptsRow { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.65); +} + +.promptsRowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.promptsRowName { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.promptsRowPreview { + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.promptsAddRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: end; + margin-top: 14px; +} + +.promptsLinkRow { + display: flex; + gap: 8px; + align-items: center; +} + +.promptsLinkRow input { + flex: 1; + min-width: 0; +} + .domainCell { display: inline-flex; align-items: center; diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index f6af15e..545382a 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { AlertTriangle, + Copy, Database, FileText, Globe, @@ -10,6 +11,7 @@ import { Plus, RotateCcw, Search, + Trash2, UserCog, } from 'lucide-react' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' @@ -46,6 +48,14 @@ import { } from '../services/businessService' import { listBusinessCategories } from '../services/categoryService' import { listUserTitles, type UserTitleOption } from '../services/userTitleService' +import { + createDomainAiPrompt, + deleteDomainAiPrompt, + listAiPromptTemplates, + listDomainAiPrompts, + updateDomainAiPrompt, +} from '../services/aiPromptService' +import type { AiPromptTemplate, DomainAiPrompt } from '../types/aiPrompt' import { Modal } from '../components/Modal' import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl' import { useToast } from '../context/ToastContext' @@ -315,6 +325,21 @@ export function BusinessesPage() { const [domainSubmitting, setDomainSubmitting] = useState(false) const [domainError, setDomainError] = useState('') + const [promptsOpen, setPromptsOpen] = useState(false) + const [promptsBusiness, setPromptsBusiness] = useState(null) + const [domainPrompts, setDomainPrompts] = useState([]) + const [allPromptTemplates, setAllPromptTemplates] = useState([]) + const [promptsLoading, setPromptsLoading] = useState(false) + const [promptsError, setPromptsError] = useState('') + const [promptEditorOpen, setPromptEditorOpen] = useState(false) + const [editingDomainPrompt, setEditingDomainPrompt] = useState(null) + const [promptDraftTemplateId, setPromptDraftTemplateId] = useState('') + const [promptDraftName, setPromptDraftName] = useState('') + const [promptDraftBody, setPromptDraftBody] = useState('') + const [promptEditorSubmitting, setPromptEditorSubmitting] = useState(false) + const [promptEditorError, setPromptEditorError] = useState('') + const [promptBusyId, setPromptBusyId] = useState(null) + const [createOpen, setCreateOpen] = useState(false) const [createName, setCreateName] = useState('') const [createNameFa, setCreateNameFa] = useState('') @@ -471,6 +496,137 @@ export function BusinessesPage() { setEditOpen(true) } + async function openAiPrompts(b: BusinessListItem) { + setPromptsBusiness(b) + setPromptsOpen(true) + setPromptsError('') + setDomainPrompts([]) + setAllPromptTemplates([]) + setPromptEditorOpen(false) + setEditingDomainPrompt(null) + + if (!b.domainId) { + setPromptsError('Add a website/domain for this business before assigning AI prompts.') + return + } + + setPromptsLoading(true) + try { + const [allTemplates, assigned] = await Promise.all([ + listAiPromptTemplates(), + listDomainAiPrompts(b.domainId), + ]) + setAllPromptTemplates(allTemplates.items) + setDomainPrompts(assigned.items) + } catch (err) { + setPromptsError( + err instanceof ApiError ? err.message : 'Unable to load AI prompts.', + ) + } finally { + setPromptsLoading(false) + } + } + + function openAddDomainPrompt() { + setEditingDomainPrompt(null) + setPromptDraftTemplateId('') + setPromptDraftName('') + setPromptDraftBody('') + setPromptEditorError('') + setPromptEditorOpen(true) + } + + function openEditDomainPrompt(prompt: DomainAiPrompt) { + setEditingDomainPrompt(prompt) + setPromptDraftTemplateId(prompt.sourceTemplateId ?? '') + setPromptDraftName(prompt.name) + setPromptDraftBody(prompt.body) + setPromptEditorError('') + setPromptEditorOpen(true) + } + + function applyTemplateToDraft(templateId: string) { + setPromptDraftTemplateId(templateId) + const template = allPromptTemplates.find((item) => item.id === templateId) + if (!template) return + setPromptDraftName(template.name) + setPromptDraftBody(template.body) + } + + async function submitDomainPromptEditor() { + if (!promptsBusiness?.domainId) return + const name = promptDraftName.trim() + const body = promptDraftBody.trim() + if (!name) { + setPromptEditorError('Name is required.') + return + } + if (!body) { + setPromptEditorError('Prompt text is required.') + return + } + + setPromptEditorSubmitting(true) + setPromptEditorError('') + try { + if (editingDomainPrompt) { + const updated = await updateDomainAiPrompt( + promptsBusiness.domainId, + editingDomainPrompt.id, + { name, body }, + ) + setDomainPrompts((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)), + ) + showToast('Prompt updated.', 'success') + } else { + const created = await createDomainAiPrompt(promptsBusiness.domainId, { + name, + body, + ...(promptDraftTemplateId + ? { sourceTemplateId: Number(promptDraftTemplateId) } + : {}), + }) + setDomainPrompts((prev) => [...prev, created]) + showToast('Prompt added.', 'success') + } + setPromptEditorOpen(false) + setEditingDomainPrompt(null) + } catch (err) { + setPromptEditorError( + err instanceof ApiError ? err.message : 'Unable to save prompt.', + ) + } finally { + setPromptEditorSubmitting(false) + } + } + + async function removeDomainPromptItem(prompt: DomainAiPrompt) { + if (!promptsBusiness?.domainId) return + setPromptBusyId(prompt.id) + try { + await deleteDomainAiPrompt(promptsBusiness.domainId, prompt.id) + setDomainPrompts((prev) => prev.filter((item) => item.id !== prompt.id)) + showToast('Prompt removed.', 'success') + } catch (err) { + showToast( + err instanceof ApiError ? err.message : 'Unable to remove prompt.', + 'error', + ) + } finally { + setPromptBusyId(null) + } + } + + async function copyDomainPromptLink(url: string) { + try { + await navigator.clipboard.writeText(url) + showToast('Public link copied.', 'success') + } catch { + showToast('Unable to copy link.', 'error') + } + } + function goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') { const params = new URLSearchParams({ businessId: String(b.id), @@ -1460,6 +1616,7 @@ export function BusinessesPage() { void openAiPrompts(b)} onAllUsers={() => goToBusinessUsers(b, 'all')} onWipeData={() => openPurge(b)} onRemove={() => setRemoveTarget(b)} @@ -1498,6 +1655,187 @@ export function BusinessesPage() { + { + if (promptEditorSubmitting) return + setPromptsOpen(false) + setPromptsBusiness(null) + setPromptsError('') + setPromptEditorOpen(false) + }} + > + {promptsError ? ( +

+ {promptsError} +

+ ) : null} + + {promptsLoading ? ( +

Loading…

+ ) : promptsBusiness?.domainId ? ( + <> +
+ + {domainPrompts.length} prompt{domainPrompts.length === 1 ? '' : 's'} + + +
+ + {domainPrompts.length === 0 ? ( +
+
+ ) : ( +
    + {domainPrompts.map((prompt) => ( +
  • +
    + {prompt.name} + {prompt.body} +
    +
    + + + +
    +
  • + ))} +
+ )} + + ) : null} + +
+
+ +
+ + + { + if (promptEditorSubmitting) return + setPromptEditorOpen(false) + setEditingDomainPrompt(null) + setPromptEditorError('') + }} + > + {promptEditorError ? ( +

+ {promptEditorError} +

+ ) : null} +
+ + +
+
+ + setPromptDraftName(e.target.value)} + placeholder="Prompt name" + disabled={promptEditorSubmitting} + /> +
+
+ +