Merge pull request 'Fixed notification composition' (#158) from finishline into main
Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
+173
-107
@@ -9,12 +9,12 @@ import {
|
||||
CheckCircle2,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Table,
|
||||
Code,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Eye,
|
||||
@@ -25,7 +25,13 @@ import {
|
||||
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
|
||||
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils';
|
||||
import {
|
||||
renderMarkdownToHtml,
|
||||
parseMarkdownToHtml,
|
||||
isHtmlContent,
|
||||
stripMarkdown,
|
||||
PFM_LOGO_DATA_URI,
|
||||
} from '../../lib/markdownUtils';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
|
||||
@@ -49,7 +55,10 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
draftToEdit,
|
||||
}) => {
|
||||
const { user, claims } = useAuth();
|
||||
const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology';
|
||||
const tenantName =
|
||||
(claims?.tenant as string) ||
|
||||
user?.tenant ||
|
||||
'Department of Science, Innovation & Technology';
|
||||
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||
@@ -60,10 +69,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toInitialRichHtml = (raw: string): string => {
|
||||
if (!raw || !raw.trim()) return '';
|
||||
if (isHtmlContent(raw)) return raw;
|
||||
return parseMarkdownToHtml(raw);
|
||||
};
|
||||
|
||||
// Initialize or populate from draft or props
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let initialHtml = '';
|
||||
if (draftToEdit) {
|
||||
setSelectedUsers(
|
||||
draftToEdit.recipientId
|
||||
@@ -77,23 +95,40 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
: []
|
||||
);
|
||||
setSubject(draftToEdit.subject || '');
|
||||
setMessage(draftToEdit.message || '');
|
||||
setPlatform(NotificationPlatform.InApp);
|
||||
initialHtml = toInitialRichHtml(draftToEdit.message || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
|
||||
} else {
|
||||
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
|
||||
setSubject(initialSubject || '');
|
||||
setMessage(initialMessage || '');
|
||||
initialHtml = toInitialRichHtml(initialMessage || '');
|
||||
setMessage(initialHtml);
|
||||
setPlatform(initialPlatform);
|
||||
}
|
||||
setActiveTab('write');
|
||||
setError(null);
|
||||
setSuccessNotice(null);
|
||||
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = initialHtml;
|
||||
}
|
||||
}, 0);
|
||||
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
|
||||
|
||||
// Restore editor innerHTML when switching back from preview tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'write' && editorRef.current) {
|
||||
if (editorRef.current.innerHTML !== message) {
|
||||
editorRef.current.innerHTML = message;
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelectUser = (user: SelectedUser) => {
|
||||
setSelectedUsers([user]);
|
||||
const handleSelectUser = (selected: SelectedUser) => {
|
||||
setSelectedUsers([selected]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -101,51 +136,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
|
||||
// Helper to insert markdown text at cursor in textarea
|
||||
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const syncEditorContent = () => {
|
||||
if (editorRef.current) {
|
||||
setMessage(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selection = text.substring(start, end) || defaultText;
|
||||
const applyFormat = (command: string, value?: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
document.execCommand(command, false, value);
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
const replacement = `${before}${selection}${after}`;
|
||||
const newText = text.substring(0, start) + replacement + text.substring(end);
|
||||
|
||||
setMessage(newText);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(
|
||||
start + before.length,
|
||||
start + before.length + selection.length
|
||||
);
|
||||
}, 0);
|
||||
const insertHtmlBlock = (htmlSnippet: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
|
||||
if (!inserted) {
|
||||
editor.innerHTML += htmlSnippet;
|
||||
}
|
||||
syncEditorContent();
|
||||
};
|
||||
|
||||
// Quick insertions for PFM branding and templates
|
||||
const insertBrandedCallout = () => {
|
||||
insertMarkdown(
|
||||
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n`
|
||||
insertHtmlBlock(
|
||||
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertMilestoneTable = () => {
|
||||
insertMarkdown(
|
||||
`| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n`
|
||||
insertHtmlBlock(
|
||||
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const insertPlatformLogo = () => {
|
||||
insertMarkdown(
|
||||
`\n\n`
|
||||
insertHtmlBlock(
|
||||
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
|
||||
);
|
||||
};
|
||||
|
||||
const hasMeaningfulContent = (html: string) => {
|
||||
if (!html) return false;
|
||||
if (stripMarkdown(html).trim().length > 0) return true;
|
||||
return /<(img|table|hr)\b/i.test(html);
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
if (!subject.trim() && !message.trim()) {
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please add a subject or message to save as draft');
|
||||
return;
|
||||
}
|
||||
@@ -157,18 +200,21 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
recipientEmail: firstUser?.email || '',
|
||||
recipientName: firstUser?.name || '',
|
||||
subject: subject.trim() || 'Untitled Draft',
|
||||
message: message.trim(),
|
||||
message: currentHtml.trim(),
|
||||
platform,
|
||||
});
|
||||
|
||||
setSuccessNotice('Draft saved successfully in Drafts folder');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 800);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
|
||||
|
||||
if (selectedUsers.length === 0) {
|
||||
setError('Please select a recipient from the tenant directory');
|
||||
return;
|
||||
@@ -179,7 +225,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
if (!hasMeaningfulContent(currentHtml)) {
|
||||
setError('Please enter a message body');
|
||||
return;
|
||||
}
|
||||
@@ -188,18 +234,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
try {
|
||||
const recipient = selectedUsers[0];
|
||||
|
||||
// Format payload according to chosen platform:
|
||||
// InApp messages are saved in markdown format.
|
||||
// Email notifications are transformed from markdown to branded executive HTML before saving.
|
||||
const finalMessage =
|
||||
platform === NotificationPlatform.Email
|
||||
? renderMarkdownToHtml(message.trim(), {
|
||||
? renderMarkdownToHtml(currentHtml.trim(), {
|
||||
applyBranding: true,
|
||||
emailFormat: true,
|
||||
subject: subject.trim(),
|
||||
tenantName,
|
||||
})
|
||||
: message.trim();
|
||||
: currentHtml.trim();
|
||||
|
||||
await sendMessage({
|
||||
recipientId: recipient.id,
|
||||
@@ -207,13 +250,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
recipientName: recipient.name,
|
||||
subject: subject.trim(),
|
||||
message: finalMessage,
|
||||
platform,
|
||||
draftId: draftToEdit?.id,
|
||||
});
|
||||
|
||||
setSuccessNotice(
|
||||
platform === NotificationPlatform.Email
|
||||
? 'Email notification rendered to HTML and dispatched!'
|
||||
: 'In-App Markdown message sent successfully!'
|
||||
? 'Email notification dispatched!'
|
||||
: 'In-App message sent successfully!'
|
||||
);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
@@ -239,7 +283,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
subject: subject || 'Notice Preview',
|
||||
tenantName,
|
||||
})
|
||||
: parseMarkdownToHtml(message);
|
||||
: message;
|
||||
|
||||
const plainCharCount = stripMarkdown(message).length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
||||
@@ -258,11 +304,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
: 'Compose New Communication'}
|
||||
</h3>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
||||
Rich Markdown Engine
|
||||
Rich Text Composer
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery
|
||||
Type and format your message using the toolbar for In-App or branded Email delivery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +358,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
<span>In-App (Markdown)</span>
|
||||
<span>In-App</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -325,7 +371,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span>Email (HTML + Brand)</span>
|
||||
<span>Email</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,12 +381,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
{platform === NotificationPlatform.InApp ? (
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell.
|
||||
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching.
|
||||
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -373,14 +419,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Markdown Editor / Preview Tabs */}
|
||||
{/* Rich Text Editor / Branded Preview Tabs */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Message Content <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
|
||||
{/* Write vs Preview Tabs */}
|
||||
{/* Compose vs Email Preview Tabs */}
|
||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
@@ -392,11 +438,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Write Markdown</span>
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
onClick={() => {
|
||||
syncEditorContent();
|
||||
setActiveTab('preview');
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
@@ -404,37 +453,48 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Preview Rendered</span>
|
||||
<span>Preview Delivery</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Toolbar (Only when activeTab === 'write') */}
|
||||
{activeTab === 'write' ? (
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||
{/* Toolbar */}
|
||||
{/* WYSIWYG Formatting Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('**', '**', 'bold text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bold (**text**)"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('bold')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('*', '*', 'italic text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Italic (*text*)"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('italic')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Heading (### )"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('underline')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Underline"
|
||||
>
|
||||
<Underline className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'H3')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Heading"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -443,25 +503,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('- ', '', 'List item')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bullet List (- )"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('1. ', '', 'First item')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Numbered List (1. )"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Blockquote (> )"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Quote Block"
|
||||
>
|
||||
<Quote className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -470,25 +533,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertMilestoneTable}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Deliverable Table"
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Code Block (```)"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('\n---\n')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Horizontal Rule (---)"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyFormat('insertHorizontalRule')}
|
||||
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Divider Line"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -498,6 +555,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
{/* Platform Brand Insertions */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertBrandedCallout}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
||||
title="Insert official PFM callout block"
|
||||
@@ -508,46 +566,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={insertPlatformLogo}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
||||
title="Embed official SVG PFM logo into markdown"
|
||||
title="Embed official PFM logo"
|
||||
>
|
||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||
<span>Embed Logo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices."
|
||||
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono"
|
||||
/>
|
||||
{/* Rich Text WYSIWYG Surface */}
|
||||
<div className="relative">
|
||||
{!hasMeaningfulContent(message) && (
|
||||
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
|
||||
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={syncEditorContent}
|
||||
onBlur={syncEditorContent}
|
||||
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Fully-Rendered Markdown / HTML Preview */
|
||||
/* Live Fully-Rendered Delivery Preview */
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
||||
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'})
|
||||
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
Subject: {subject || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{message.trim() ? (
|
||||
{hasMeaningfulContent(message) ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-xs leading-relaxed"
|
||||
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||
Type your markdown message in the Write tab to see it live-rendered here.
|
||||
Type your message in the Compose tab to preview how it will appear upon delivery.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -560,7 +626,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
<span>Tenant Partition:</span>
|
||||
<strong className="text-slate-300">{tenantName}</strong>
|
||||
</span>
|
||||
<span className="font-mono">{message.length} characters</span>
|
||||
<span className="font-mono">{plainCharCount} characters</span>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer Action Buttons */}
|
||||
@@ -608,7 +674,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
|
||||
)}
|
||||
<span>
|
||||
{platform === NotificationPlatform.Email
|
||||
? 'Dispatch Email (HTML)'
|
||||
? 'Dispatch Email'
|
||||
: 'Send In-App Message'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Code,
|
||||
Eye,
|
||||
Copy,
|
||||
Check,
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
content,
|
||||
subject,
|
||||
showBrandingHeader = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
}, [content, isHtml]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
navigator.clipboard.writeText(stripMarkdown(content) || content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
{showBrandingHeader && (
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
||||
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
|
||||
<span className="text-[11px] tracking-tight">PFM Communications</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-blue-400 font-mono">
|
||||
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
|
||||
In-App Message
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toggle View Mode */}
|
||||
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('rendered')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
||||
viewMode === 'rendered'
|
||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Rendered</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('raw')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
||||
viewMode === 'raw'
|
||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
<span>Raw Source</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
||||
title="Copy content to clipboard"
|
||||
title="Copy message text to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content Rendering */}
|
||||
{viewMode === 'rendered' ? (
|
||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||
{isHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||
<div
|
||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Branded Footer Stamp */}
|
||||
{showBrandingHeader && (
|
||||
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
<span>Verified PFM Secure Platform Formatting</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{content.length} characters
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,70 +39,73 @@ export function useInAppNotifications() {
|
||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||
|
||||
// Starred messages set
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() => {
|
||||
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(starredStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
const stored = localStorage.getItem(key);
|
||||
const parsed: T[] = stored ? JSON.parse(stored) : [];
|
||||
if (fallbackKey && fallbackKey !== key) {
|
||||
const fallbackStored = localStorage.getItem(fallbackKey);
|
||||
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
|
||||
if (fallbackParsed.length > 0) {
|
||||
const merged = [...parsed];
|
||||
for (const item of fallbackParsed) {
|
||||
const itemId = (item as any)?.id ?? item;
|
||||
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
localStorage.removeItem(fallbackKey);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Starred messages set
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
|
||||
);
|
||||
|
||||
// Trashed messages set
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(trashStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
|
||||
);
|
||||
|
||||
// Local drafts list
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(draftsStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
|
||||
);
|
||||
|
||||
// Local sent messages history
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(sentStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
|
||||
);
|
||||
|
||||
// Sync state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
|
||||
} catch {}
|
||||
}, [starredIds, starredStorageKey]);
|
||||
// Synchronize across hook instances and userId changes
|
||||
const syncFromStorage = useCallback(() => {
|
||||
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
|
||||
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
|
||||
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
|
||||
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
|
||||
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
|
||||
} catch {}
|
||||
}, [trashedIds, trashStorageKey]);
|
||||
syncFromStorage();
|
||||
}, [syncFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
|
||||
} catch {}
|
||||
}, [drafts, draftsStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
|
||||
} catch {}
|
||||
}, [localSent, sentStorageKey]);
|
||||
const handleSync = () => syncFromStorage();
|
||||
window.addEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.addEventListener('storage', handleSync);
|
||||
return () => {
|
||||
window.removeEventListener('pfm-mailbox-sync', handleSync);
|
||||
window.removeEventListener('storage', handleSync);
|
||||
};
|
||||
}, [syncFromStorage]);
|
||||
|
||||
// Fetch notifications from backend
|
||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||
@@ -131,23 +134,16 @@ export function useInAppNotifications() {
|
||||
);
|
||||
}, [rawNotifications]);
|
||||
|
||||
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
|
||||
const allMailboxNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n =>
|
||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [rawNotifications, trashedIds]);
|
||||
|
||||
// Active in-app notifications in inbox
|
||||
// Active in-app notifications in inbox (excluding trashed and drafts)
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Active mailbox notifications (always In-App notifications)
|
||||
const allMailboxNotifications = inboxNotifications;
|
||||
|
||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||
const unreadNotifications = useMemo(() => {
|
||||
return inboxNotifications.filter(
|
||||
@@ -161,20 +157,19 @@ export function useInAppNotifications() {
|
||||
const unreadCount = unreadNotifications.length;
|
||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||
|
||||
// Starred notifications
|
||||
// Starred notifications (In-App only)
|
||||
const starredNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
return inAppNotifications.filter(
|
||||
n =>
|
||||
starredIds.includes(String(n.id)) &&
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
|
||||
!trashedIds.includes(String(n.id))
|
||||
);
|
||||
}, [rawNotifications, starredIds, trashedIds]);
|
||||
}, [inAppNotifications, starredIds, trashedIds]);
|
||||
|
||||
// Trashed notifications
|
||||
// Trashed notifications (In-App only)
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
}, [rawNotifications, trashedIds]);
|
||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
@@ -206,27 +201,61 @@ export function useInAppNotifications() {
|
||||
// Toggle star
|
||||
const toggleStar = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
|
||||
}, []);
|
||||
setStarredIds(prev => {
|
||||
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(starredStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [starredStorageKey]);
|
||||
|
||||
// Delete message (move to trash)
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
|
||||
}, []);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.includes(strId) ? prev : [...prev, strId];
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Restore from trash
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
||||
}, []);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Permanent delete
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
||||
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
|
||||
}, []);
|
||||
setTrashedIds(prev => {
|
||||
const next = prev.filter(x => x !== strId);
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
setLocalSent(prev => {
|
||||
const next = prev.filter(n => String(n.id) !== strId);
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [trashStorageKey, sentStorageKey]);
|
||||
|
||||
// Draft management
|
||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||
@@ -237,15 +266,31 @@ export function useInAppNotifications() {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setDrafts(prev => {
|
||||
const filtered = prev.filter(d => d.id !== draftId);
|
||||
return [newDraft, ...filtered];
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const filtered = base.filter(d => d.id !== draftId);
|
||||
const next = [newDraft, ...filtered];
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
return draftId;
|
||||
}, []);
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
const deleteDraft = useCallback((id: string) => {
|
||||
setDrafts(prev => prev.filter(d => d.id !== id));
|
||||
}, []);
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
|
||||
const base = current.length >= prev.length ? current : prev;
|
||||
const next = base.filter(d => d.id !== id);
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [draftsStorageKey, readStorageList]);
|
||||
|
||||
// Send new notification (InApp or Email)
|
||||
const sendMessageMutation = useMutation({
|
||||
@@ -288,7 +333,14 @@ export function useInAppNotifications() {
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLocalSent(prev => [sentItem, ...prev]);
|
||||
setLocalSent(prev => {
|
||||
const next = [sentItem, ...prev];
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
// If sending a draft, remove the draft
|
||||
if (payload.draftId) {
|
||||
|
||||
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<p>') ||
|
||||
(trimmed.includes('</') && trimmed.includes('>'))
|
||||
trimmed.startsWith('<p') ||
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<ul') ||
|
||||
trimmed.startsWith('<ol') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
(trimmed.includes('</') && trimmed.includes('>')) ||
|
||||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +229,14 @@ export function renderMarkdownToHtml(
|
||||
markdown: string,
|
||||
options?: MarkdownRenderOptions
|
||||
): string {
|
||||
const contentHtml = parseMarkdownToHtml(markdown);
|
||||
const trimmed = (markdown || '').trim();
|
||||
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const contentHtml = isHtmlContent(trimmed)
|
||||
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
: parseMarkdownToHtml(trimmed);
|
||||
|
||||
if (!options?.applyBranding && !options?.emailFormat) {
|
||||
return contentHtml;
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
RotateCcw,
|
||||
X,
|
||||
Smartphone,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||
@@ -28,7 +27,6 @@ import { useAuth } from '../auth/AuthProvider';
|
||||
import { stripMarkdown } from '../lib/markdownUtils';
|
||||
|
||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||
type PlatformFilter = 'all' | 'inapp' | 'email';
|
||||
|
||||
export const InboxPage: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -60,8 +58,6 @@ export const InboxPage: React.FC = () => {
|
||||
|
||||
// Active folder
|
||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||
// Platform filter
|
||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
||||
// Search query
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// Filter for unread only in Inbox
|
||||
@@ -82,11 +78,11 @@ export const InboxPage: React.FC = () => {
|
||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||
|
||||
// Active folder message items
|
||||
// Active folder message items (always In-App notifications)
|
||||
const folderMessages = useMemo(() => {
|
||||
switch (activeFolder) {
|
||||
case 'inbox':
|
||||
return allMailboxNotifications;
|
||||
return inboxNotifications;
|
||||
case 'starred':
|
||||
return starredNotifications;
|
||||
case 'sent':
|
||||
@@ -96,20 +92,13 @@ export const InboxPage: React.FC = () => {
|
||||
case 'drafts':
|
||||
return [];
|
||||
default:
|
||||
return allMailboxNotifications;
|
||||
return inboxNotifications;
|
||||
}
|
||||
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||
|
||||
// Filtered by search, platform, and unread toggle
|
||||
// Filtered by search and unread toggle
|
||||
const filteredMessages = useMemo(() => {
|
||||
return folderMessages.filter(item => {
|
||||
// Platform filter
|
||||
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
|
||||
return false;
|
||||
}
|
||||
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
|
||||
return false;
|
||||
}
|
||||
// Unread only filter
|
||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||
@@ -122,7 +111,7 @@ export const InboxPage: React.FC = () => {
|
||||
const dest = (item.destination || '').toLowerCase();
|
||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||
});
|
||||
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
|
||||
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
|
||||
|
||||
// Filtered drafts for Drafts folder
|
||||
const filteredDrafts = useMemo(() => {
|
||||
@@ -266,11 +255,11 @@ export const InboxPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
Markdown & Branded HTML Engine
|
||||
In-App Messaging
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
In-App markdown messaging and executive branded email notifications
|
||||
In-App notifications and internal platform communications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,10 +395,14 @@ export const InboxPage: React.FC = () => {
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span>Drafts</span>
|
||||
</div>
|
||||
{drafts.length > 0 && (
|
||||
{drafts.length > 0 ? (
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
||||
{drafts.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -436,57 +429,6 @@ export const InboxPage: React.FC = () => {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Platform Filter Buttons */}
|
||||
<div className="pt-3 border-t border-slate-800/80 space-y-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
||||
Channel Filter
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('all')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'all'
|
||||
? 'bg-slate-800 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>All Channels</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('inapp')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'inapp'
|
||||
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Markdown</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('email')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'email'
|
||||
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Email HTML</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filter: Unread Toggle */}
|
||||
{activeFolder === 'inbox' && (
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
@@ -507,10 +449,10 @@ export const InboxPage: React.FC = () => {
|
||||
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Format Standards</span>
|
||||
<span>In-App Notifications</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML.
|
||||
Messages are delivered directly to your In-App Mailbox and Notification Bell.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -571,7 +513,7 @@ export const InboxPage: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
||||
Draft (Markdown)
|
||||
Draft
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(d.updatedAt)}
|
||||
@@ -620,7 +562,6 @@ export const InboxPage: React.FC = () => {
|
||||
const isSelected = selectedMessage?.id === item.id;
|
||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||
const isStarred = starredIds.includes(String(item.id));
|
||||
const isEmail = Number(item.platform) === NotificationPlatform.Email;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -649,15 +590,9 @@ export const InboxPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isEmail ? (
|
||||
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
Email
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
InApp
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
In-App
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -707,7 +642,7 @@ export const InboxPage: React.FC = () => {
|
||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
||||
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||
Click any draft on the left pane to resume composing in Markdown and dispatch your notification.
|
||||
Click any draft on the left pane to resume composing and dispatch your notification.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -725,7 +660,7 @@ export const InboxPage: React.FC = () => {
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
||||
Select an in-app conversation or email notice from the list to view its rendered markdown and attachments.
|
||||
Select an in-app conversation or email notice from the list to view its contents and attachments.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -815,17 +750,10 @@ export const InboxPage: React.FC = () => {
|
||||
{selectedMessage.subject || 'In-App Notification'}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
|
||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Mail className="w-3 h-3 text-emerald-400" />
|
||||
Channel: Email (Branded HTML)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||
Channel: InApp (Markdown)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||
Channel: In-App
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||
ID: #{selectedMessage.id}
|
||||
@@ -852,7 +780,7 @@ export const InboxPage: React.FC = () => {
|
||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
|
||||
(In-App Notification)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
@@ -884,10 +812,10 @@ export const InboxPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Quick Markdown Reply</span>
|
||||
<span>Quick Reply</span>
|
||||
</h4>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Supports Markdown formatting (e.g. **bold**, - lists, `code`)
|
||||
Send a quick response below or open the rich text composer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -902,8 +830,8 @@ export const InboxPage: React.FC = () => {
|
||||
rows={4}
|
||||
value={inlineReplyText}
|
||||
onChange={e => setInlineReplyText(e.target.value)}
|
||||
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
|
||||
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y font-mono leading-relaxed"
|
||||
placeholder="Type your reply..."
|
||||
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -27,5 +27,5 @@ public static class Constants
|
||||
public const int ShortLabelLength = 100;
|
||||
public const int TextLength = 1024;
|
||||
public const int MediumTextLength = 2048;
|
||||
public const int LargeTextLength = 4096;
|
||||
public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
|
||||
}
|
||||
+2519
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IncreasedLargeTextLengthTo1MB : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Findings",
|
||||
table: "SiteVisits",
|
||||
type: "character varying(1048576)",
|
||||
maxLength: 1048576,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(4096)",
|
||||
oldMaxLength: 4096,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Message",
|
||||
table: "Notifications",
|
||||
type: "character varying(1048576)",
|
||||
maxLength: 1048576,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(4096)",
|
||||
oldMaxLength: 4096);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Findings",
|
||||
table: "SiteVisits",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(1048576)",
|
||||
oldMaxLength: 1048576,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Message",
|
||||
table: "Notifications",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(1048576)",
|
||||
oldMaxLength: 1048576);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -958,8 +958,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
.HasMaxLength(1048576)
|
||||
.HasColumnType("character varying(1048576)");
|
||||
|
||||
b.Property<long>("OrganisationId")
|
||||
.HasColumnType("bigint");
|
||||
@@ -1405,8 +1405,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Findings")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
.HasMaxLength(1048576)
|
||||
.HasColumnType("character varying(1048576)");
|
||||
|
||||
b.Property<string>("InspectorName")
|
||||
.IsRequired()
|
||||
|
||||
Reference in New Issue
Block a user