formatedd description implemente along with request number format chnged
This commit is contained in:
parent
2681631d5d
commit
2161cc59ca
77
src/components/common/FormattedDescription.tsx
Normal file
77
src/components/common/FormattedDescription.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/components/ui/utils";
|
||||
|
||||
interface FormattedDescriptionProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormattedDescription Component
|
||||
*
|
||||
* Renders HTML content with proper styling for lists, tables, and other formatted content.
|
||||
* Use this component to display descriptions that may contain HTML formatting.
|
||||
*/
|
||||
export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
|
||||
const processedContent = React.useMemo(() => {
|
||||
if (!content) return '';
|
||||
|
||||
// Wrap tables that aren't already wrapped in a scrollable container using regex
|
||||
// Match <table> tags that aren't already inside a .table-wrapper
|
||||
let processed = content;
|
||||
|
||||
// Pattern to match table tags that aren't already wrapped
|
||||
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
|
||||
|
||||
processed = processed.replace(tablePattern, (match) => {
|
||||
// Check if this table is already wrapped
|
||||
if (match.includes('table-wrapper')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Wrap the table in a scrollable container
|
||||
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`;
|
||||
});
|
||||
|
||||
return processed;
|
||||
}, [content]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm text-gray-700 max-w-none",
|
||||
// Horizontal scrolling for smaller screens
|
||||
"overflow-x-auto",
|
||||
"md:overflow-x-visible",
|
||||
// Lists
|
||||
"[&_ul]:list-disc [&_ul]:ml-6 [&_ul]:my-2 [&_ul]:list-outside",
|
||||
"[&_ol]:list-decimal [&_ol]:ml-6 [&_ol]:my-2 [&_ol]:list-outside",
|
||||
"[&_li]:my-1 [&_li]:pl-2",
|
||||
// Table wrapper for scrolling
|
||||
"[&_.table-wrapper]:overflow-x-auto [&_.table-wrapper]:max-w-full [&_.table-wrapper]:my-2 [&_.table-wrapper]:-mx-2 [&_.table-wrapper]:px-2",
|
||||
"[&_.table-wrapper_table]:border-collapse [&_.table-wrapper_table]:border [&_.table-wrapper_table]:border-gray-300 [&_.table-wrapper_table]:min-w-full",
|
||||
"[&_.table-wrapper_table_td]:border [&_.table-wrapper_table_td]:border-gray-300 [&_.table-wrapper_table_td]:px-3 [&_.table-wrapper_table_td]:py-2 [&_.table-wrapper_table_td]:text-sm [&_.table-wrapper_table_td]:whitespace-nowrap",
|
||||
"[&_.table-wrapper_table_th]:border [&_.table-wrapper_table_th]:border-gray-300 [&_.table-wrapper_table_th]:px-3 [&_.table-wrapper_table_th]:py-2 [&_.table-wrapper_table_th]:bg-gray-50 [&_.table-wrapper_table_th]:font-semibold [&_.table-wrapper_table_th]:text-sm [&_.table-wrapper_table_th]:text-left [&_.table-wrapper_table_th]:whitespace-nowrap",
|
||||
"[&_.table-wrapper_table_tr:nth-child(even)]:bg-gray-50",
|
||||
// Direct table styles (fallback for tables not wrapped)
|
||||
"[&_table]:border-collapse [&_table]:my-2 [&_table]:border [&_table]:border-gray-300",
|
||||
"[&_table_td]:border [&_table_td]:border-gray-300 [&_table_td]:px-3 [&_table_td]:py-2 [&_table_td]:text-sm",
|
||||
"[&_table_th]:border [&_table_th]:border-gray-300 [&_table_th]:px-3 [&_table_th]:py-2 [&_table_th]:bg-gray-50 [&_table_th]:font-semibold [&_table_th]:text-sm [&_table_th]:text-left",
|
||||
"[&_table_tr:nth-child(even)]:bg-gray-50",
|
||||
// Text formatting
|
||||
"[&_p]:my-1 [&_p]:leading-relaxed",
|
||||
"[&_strong]:font-bold",
|
||||
"[&_em]:italic",
|
||||
"[&_u]:underline",
|
||||
"[&_h1]:text-xl [&_h1]:font-bold [&_h1]:my-2",
|
||||
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2",
|
||||
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: processedContent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
0
src/components/ui/ckeditor-wrapper.tsx
Normal file
0
src/components/ui/ckeditor-wrapper.tsx
Normal file
563
src/components/ui/rich-text-editor.tsx
Normal file
563
src/components/ui/rich-text-editor.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
||||
|
||||
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RichTextEditor Component
|
||||
*
|
||||
* Preserves formatting (lists, tables, etc.) when pasting content.
|
||||
* Uses contentEditable div to maintain HTML structure.
|
||||
*/
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Enter text...",
|
||||
className,
|
||||
minHeight = "120px",
|
||||
...props
|
||||
}: RichTextEditorProps) {
|
||||
const editorRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const [activeFormats, setActiveFormats] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// Sync external value to editor
|
||||
React.useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
// Only update if the value actually changed externally
|
||||
const currentValue = editorRef.current.innerHTML;
|
||||
if (currentValue !== value) {
|
||||
editorRef.current.innerHTML = value || '';
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Clean HTML from Word/Office documents - remove style tags, comments, and metadata
|
||||
const cleanWordHTML = React.useCallback((html: string): string => {
|
||||
// Remove HTML comments (like Word style definitions)
|
||||
html = html.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// Remove style tags (Word CSS)
|
||||
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
|
||||
// Remove script tags
|
||||
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// Remove meta tags
|
||||
html = html.replace(/<meta[^>]*>/gi, '');
|
||||
|
||||
// Remove Word-specific classes and attributes
|
||||
html = html.replace(/\s*class="Mso[^"]*"/gi, '');
|
||||
html = html.replace(/\s*class="mso[^"]*"/gi, '');
|
||||
html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, '');
|
||||
html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, '');
|
||||
|
||||
// Remove xmlns attributes
|
||||
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
|
||||
|
||||
// Remove o:p tags (Word paragraph markers)
|
||||
html = html.replace(/<\/?o:p[^>]*>/gi, '');
|
||||
|
||||
// Remove v:shapes and other Word-specific elements
|
||||
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||
html = html.replace(/<v:[^>]*\/>/gi, '');
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
|
||||
html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
|
||||
|
||||
// Remove excessive whitespace
|
||||
html = html.replace(/\s+/g, ' ');
|
||||
html = html.trim();
|
||||
|
||||
return html;
|
||||
}, []);
|
||||
|
||||
// Handle paste event to preserve formatting
|
||||
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const clipboardData = e.clipboardData;
|
||||
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
|
||||
|
||||
// Clean Word/Office metadata if HTML
|
||||
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
|
||||
pastedData = cleanWordHTML(pastedData);
|
||||
}
|
||||
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
// Create a temporary container to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = pastedData;
|
||||
|
||||
// Clean and preserve formatting
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Process each node to preserve lists, tables, and basic formatting
|
||||
Array.from(tempDiv.childNodes).forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as HTMLElement;
|
||||
|
||||
// Preserve lists (ul, ol)
|
||||
if (element.tagName === 'UL' || element.tagName === 'OL') {
|
||||
const list = element.cloneNode(true) as HTMLElement;
|
||||
// Remove all inline styles and classes
|
||||
list.removeAttribute('style');
|
||||
list.removeAttribute('class');
|
||||
// Clean up list items but keep structure
|
||||
list.querySelectorAll('li').forEach((li) => {
|
||||
li.removeAttribute('style');
|
||||
li.removeAttribute('class');
|
||||
// Keep text content and basic formatting
|
||||
const text = li.textContent || '';
|
||||
if (text.trim()) {
|
||||
li.textContent = text.trim();
|
||||
}
|
||||
});
|
||||
fragment.appendChild(list);
|
||||
}
|
||||
// Preserve tables - wrap in scrollable container
|
||||
else if (element.tagName === 'TABLE') {
|
||||
const table = element.cloneNode(true) as HTMLElement;
|
||||
// Remove all inline styles and classes
|
||||
table.removeAttribute('style');
|
||||
table.removeAttribute('class');
|
||||
// Clean table cells
|
||||
table.querySelectorAll('td, th').forEach((cell) => {
|
||||
cell.removeAttribute('style');
|
||||
cell.removeAttribute('class');
|
||||
});
|
||||
// Wrap table in scrollable container for mobile
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-wrapper';
|
||||
wrapper.style.overflowX = 'auto';
|
||||
wrapper.style.maxWidth = '100%';
|
||||
wrapper.style.margin = '8px 0';
|
||||
wrapper.appendChild(table);
|
||||
fragment.appendChild(wrapper);
|
||||
}
|
||||
// Preserve paragraphs and divs
|
||||
else if (element.tagName === 'P' || element.tagName === 'DIV') {
|
||||
const p = document.createElement('p');
|
||||
// Extract text content and preserve line breaks
|
||||
const innerHTML = element.innerHTML;
|
||||
// Remove style tags and comments from inner HTML
|
||||
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '');
|
||||
p.innerHTML = cleaned;
|
||||
p.removeAttribute('style');
|
||||
p.removeAttribute('class');
|
||||
fragment.appendChild(p);
|
||||
}
|
||||
// Preserve line breaks
|
||||
else if (element.tagName === 'BR') {
|
||||
fragment.appendChild(element.cloneNode(true));
|
||||
}
|
||||
// For other elements, extract text content
|
||||
else {
|
||||
const text = element.textContent || '';
|
||||
if (text.trim()) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = text.trim();
|
||||
fragment.appendChild(p);
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
if (text.trim()) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = text.trim();
|
||||
fragment.appendChild(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If no structured content, insert as plain text with line breaks
|
||||
if (fragment.childNodes.length === 0) {
|
||||
const plainText = clipboardData.getData('text/plain');
|
||||
const lines = plainText.split('\n');
|
||||
lines.forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = line.trim();
|
||||
fragment.appendChild(p);
|
||||
} else {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
range.insertNode(fragment);
|
||||
|
||||
// Move cursor to end of inserted content
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// Trigger onChange
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
}, [onChange, cleanWordHTML]);
|
||||
|
||||
// Check active formats (bold, italic, etc.)
|
||||
const checkActiveFormats = React.useCallback(() => {
|
||||
if (!editorRef.current || !isFocused) return;
|
||||
|
||||
const formats = new Set<string>();
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
let element: HTMLElement | null = null;
|
||||
|
||||
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
||||
element = commonAncestor.parentElement;
|
||||
} else {
|
||||
element = commonAncestor as HTMLElement;
|
||||
}
|
||||
|
||||
while (element && element !== editorRef.current) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === 'strong' || tagName === 'b') formats.add('bold');
|
||||
if (tagName === 'em' || tagName === 'i') formats.add('italic');
|
||||
if (tagName === 'u') formats.add('underline');
|
||||
if (tagName === 'h1') formats.add('h1');
|
||||
if (tagName === 'h2') formats.add('h2');
|
||||
if (tagName === 'h3') formats.add('h3');
|
||||
if (tagName === 'ul') formats.add('ul');
|
||||
if (tagName === 'ol') formats.add('ol');
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.textAlign === 'center') formats.add('center');
|
||||
if (style.textAlign === 'right') formats.add('right');
|
||||
if (style.textAlign === 'left') formats.add('left');
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveFormats(formats);
|
||||
}, [isFocused]);
|
||||
|
||||
// Apply formatting command
|
||||
const applyFormat = React.useCallback((command: string, value?: string) => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Restore focus if needed
|
||||
if (!isFocused) {
|
||||
editorRef.current.focus();
|
||||
}
|
||||
|
||||
// Save current selection
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
// If no selection, focus the editor
|
||||
editorRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute formatting command
|
||||
document.execCommand(command, false, value);
|
||||
|
||||
// Update content
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
|
||||
// Check active formats after a short delay
|
||||
setTimeout(checkActiveFormats, 10);
|
||||
}, [isFocused, onChange, checkActiveFormats]);
|
||||
|
||||
// Handle input changes
|
||||
const handleInput = React.useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
checkActiveFormats();
|
||||
}, [onChange, checkActiveFormats]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// Ctrl+B for Bold
|
||||
if (e.ctrlKey && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
applyFormat('bold');
|
||||
return;
|
||||
}
|
||||
// Ctrl+I for Italic
|
||||
if (e.ctrlKey && e.key === 'i') {
|
||||
e.preventDefault();
|
||||
applyFormat('italic');
|
||||
return;
|
||||
}
|
||||
// Ctrl+U for Underline
|
||||
if (e.ctrlKey && e.key === 'u') {
|
||||
e.preventDefault();
|
||||
applyFormat('underline');
|
||||
return;
|
||||
}
|
||||
}, [applyFormat]);
|
||||
|
||||
// Handle focus
|
||||
const handleFocus = React.useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
// Handle blur
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// Handle selection change to update active formats
|
||||
React.useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
checkActiveFormats();
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
};
|
||||
}, [isFocused, checkActiveFormats]);
|
||||
|
||||
return (
|
||||
<div className="relative border border-gray-300 rounded-md bg-white">
|
||||
{/* Formatting Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-gray-200 bg-gray-50 rounded-t-md flex-wrap">
|
||||
{/* Text Formatting */}
|
||||
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('bold') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('italic') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('underline') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('underline')}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Headings */}
|
||||
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('h1') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('formatBlock', '<h1>')}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('h2') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('formatBlock', '<h2>')}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('h3') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('formatBlock', '<h3>')}
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lists */}
|
||||
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('ul') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('insertUnorderedList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('ol') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('insertOrderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('left') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('justifyLeft')}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('center') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('justifyCenter')}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
activeFormats.has('right') && "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
onClick={() => applyFormat('justifyRight')}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onPaste={handlePaste}
|
||||
onInput={handleInput}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseUp={checkActiveFormats}
|
||||
onKeyUp={checkActiveFormats}
|
||||
data-placeholder={placeholder}
|
||||
className={cn(
|
||||
"w-full rounded-b-md border-0 px-3 py-2 text-base transition-all outline-none",
|
||||
"bg-white text-gray-900",
|
||||
"min-h-[120px]",
|
||||
"overflow-y-auto",
|
||||
// Horizontal scrolling for smaller screens
|
||||
"overflow-x-auto",
|
||||
"md:overflow-x-visible",
|
||||
"md:text-sm",
|
||||
// Placeholder styling - match Input and Textarea
|
||||
"empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none",
|
||||
// Focus styling
|
||||
"focus-visible:outline-none",
|
||||
// Preserve formatting styles
|
||||
"[&_ul]:list-disc [&_ul]:ml-6 [&_ul]:my-2 [&_ul]:list-outside",
|
||||
"[&_ol]:list-decimal [&_ol]:ml-6 [&_ol]:my-2 [&_ol]:list-outside",
|
||||
"[&_li]:my-1 [&_li]:pl-2",
|
||||
// Table wrapper for scrolling
|
||||
"[&_.table-wrapper]:overflow-x-auto [&_.table-wrapper]:max-w-full [&_.table-wrapper]:my-2",
|
||||
"[&_.table-wrapper_table]:border-collapse [&_.table-wrapper_table]:border [&_.table-wrapper_table]:border-gray-300 [&_.table-wrapper_table]:min-w-full",
|
||||
"[&_.table-wrapper_table_td]:border [&_.table-wrapper_table_td]:border-gray-300 [&_.table-wrapper_table_td]:px-3 [&_.table-wrapper_table_td]:py-2 [&_.table-wrapper_table_td]:text-sm [&_.table-wrapper_table_td]:whitespace-nowrap",
|
||||
"[&_.table-wrapper_table_th]:border [&_.table-wrapper_table_th]:border-gray-300 [&_.table-wrapper_table_th]:px-3 [&_.table-wrapper_table_th]:py-2 [&_.table-wrapper_table_th]:bg-gray-50 [&_.table-wrapper_table_th]:font-semibold [&_.table-wrapper_table_th]:text-sm [&_.table-wrapper_table_th]:text-left [&_.table-wrapper_table_th]:whitespace-nowrap",
|
||||
"[&_.table-wrapper_table_tr:nth-child(even)]:bg-gray-50",
|
||||
// Direct table styles (for tables not wrapped)
|
||||
"[&_table]:border-collapse [&_table]:my-2 [&_table]:border [&_table]:border-gray-300 [&_table]:w-full",
|
||||
"[&_table_td]:border [&_table_td]:border-gray-300 [&_table_td]:px-3 [&_table_td]:py-2 [&_table_td]:text-sm",
|
||||
"[&_table_th]:border [&_table_th]:border-gray-300 [&_table_th]:px-3 [&_table_th]:py-2 [&_table_th]:bg-gray-50 [&_table_th]:font-semibold [&_table_th]:text-sm [&_table_th]:text-left",
|
||||
"[&_table_tr:nth-child(even)]:bg-gray-50",
|
||||
"[&_p]:my-1 [&_p]:leading-relaxed",
|
||||
"[&_strong]:font-bold",
|
||||
"[&_em]:italic",
|
||||
"[&_u]:underline",
|
||||
"[&_h1]:text-xl [&_h1]:font-bold [&_h1]:my-2",
|
||||
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2",
|
||||
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",
|
||||
className
|
||||
)}
|
||||
style={{ minHeight }}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -70,13 +70,16 @@ export function BasicInformationStep({
|
||||
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Explain what you need approval for, why it's needed, and any relevant background information.
|
||||
<span className="block mt-1 text-xs text-blue-600">
|
||||
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
|
||||
</span>
|
||||
</p>
|
||||
<Textarea
|
||||
id="description"
|
||||
<RichTextEditor
|
||||
value={formData.description || ''}
|
||||
onChange={(html) => updateFormData('description', html)}
|
||||
placeholder="Provide comprehensive details about your request including scope, objectives, expected outcomes, and any supporting context that will help approvers make an informed decision."
|
||||
className="min-h-[120px] text-base border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm resize-none"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateFormData('description', e.target.value)}
|
||||
className="min-h-[120px] text-base border-2 border-gray-300 focus-within:border-blue-500 bg-white shadow-sm"
|
||||
minHeight="120px"
|
||||
data-testid="basic-information-description-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { motion } from 'framer-motion';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||
import { CheckCircle, Rocket, FileText, Users, Eye, Upload, Flame, Target, TrendingUp, DollarSign } from 'lucide-react';
|
||||
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||
|
||||
@ -103,9 +104,9 @@ export function ReviewSubmitStep({
|
||||
<CardContent className="space-y-4">
|
||||
<div data-testid="review-submit-basic-info-description">
|
||||
<Label className="font-semibold">Description</Label>
|
||||
<p className="text-sm text-gray-700 mt-1 p-3 bg-gray-50 rounded-lg border">
|
||||
{formData.description}
|
||||
</p>
|
||||
<div className="mt-1 p-3 bg-gray-50 rounded-lg border">
|
||||
<FormattedDescription content={formData.description || ''} />
|
||||
</div>
|
||||
</div>
|
||||
{formData.amount && (
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200" data-testid="review-submit-basic-info-financial">
|
||||
|
||||
@ -138,8 +138,9 @@ export function useConclusionRemark(
|
||||
* 6. Handle errors with user-friendly messages
|
||||
*/
|
||||
const handleFinalizeConclusion = async () => {
|
||||
// Validation: Ensure conclusion is not empty
|
||||
if (!conclusionRemark.trim()) {
|
||||
// Validation: Ensure conclusion is not empty (strip HTML tags for validation)
|
||||
const textContent = conclusionRemark.replace(/<[^>]*>/g, '').trim();
|
||||
if (!textContent) {
|
||||
setActionStatus?.({
|
||||
success: false,
|
||||
title: 'Validation Error',
|
||||
@ -149,6 +150,17 @@ export function useConclusionRemark(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check character count (text content only, not HTML)
|
||||
if (textContent.length > 2000) {
|
||||
setActionStatus?.({
|
||||
success: false,
|
||||
title: 'Validation Error',
|
||||
message: 'Conclusion remark exceeds 2000 characters limit'
|
||||
});
|
||||
setShowActionStatusModal?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setConclusionSubmitting(true);
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { type DateRange } from '@/services/dashboard.service';
|
||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||
@ -40,6 +40,12 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
// Determine user role
|
||||
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
|
||||
|
||||
// Toggle for admin to switch between admin view and personal view
|
||||
const [viewAsUser, setViewAsUser] = useState(false);
|
||||
|
||||
// Effective view mode: if admin and viewAsUser is true, show as normal user
|
||||
const effectiveIsAdmin = isAdmin && !viewAsUser;
|
||||
|
||||
// Filters
|
||||
const filters = useDashboardFilters();
|
||||
const { dateRange, customStartDate, customEndDate, showCustomDatePicker, handleDateRangeChange, handleApplyCustomDate, resetCustomDates, setCustomStartDate, setCustomEndDate, setShowCustomDatePicker } = filters;
|
||||
@ -63,7 +69,9 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
|
||||
// Data fetching
|
||||
const dashboardData = useDashboardData({
|
||||
isAdmin,
|
||||
isAdmin: effectiveIsAdmin,
|
||||
viewAsUser: isAdmin && viewAsUser, // Pass viewAsUser flag for data filtering
|
||||
userId: (user as any)?.userId, // Pass userId for filtering when viewing as user
|
||||
dateRange,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
@ -143,7 +151,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
fetchDashboardData(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange, customStartDate, customEndDate]);
|
||||
}, [dateRange, customStartDate, customEndDate, viewAsUser]);
|
||||
|
||||
// Quick actions
|
||||
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate), [isAdmin, onNewRequest, onNavigate]);
|
||||
@ -176,10 +184,18 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto p-3 sm:p-4" data-testid="dashboard">
|
||||
<DashboardHero isAdmin={isAdmin} quickActions={quickActions} />
|
||||
<DashboardHero
|
||||
isAdmin={isAdmin}
|
||||
effectiveIsAdmin={effectiveIsAdmin}
|
||||
viewAsUser={viewAsUser}
|
||||
onToggleView={setViewAsUser}
|
||||
quickActions={quickActions}
|
||||
userDisplayName={(user as any)?.displayName}
|
||||
userEmail={(user as any)?.email}
|
||||
/>
|
||||
|
||||
<DashboardFiltersBar
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={effectiveIsAdmin}
|
||||
dateRange={dateRange}
|
||||
customStartDate={customStartDate}
|
||||
customEndDate={customEndDate}
|
||||
@ -195,7 +211,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
/>
|
||||
|
||||
{/* KPI Cards Section */}
|
||||
{isAdmin ? (
|
||||
{effectiveIsAdmin ? (
|
||||
<AdminKPICards
|
||||
kpis={kpis}
|
||||
priorityDistribution={priorityDistribution}
|
||||
@ -221,14 +237,14 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
{/* Alerts and Activity Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6 h-[90vh] min-h-[720px] lg:h-[60vh] lg:min-h-[480px]" data-testid="dashboard-alerts-activity">
|
||||
<CriticalAlertsSection
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={effectiveIsAdmin}
|
||||
breachedRequests={breachedRequests}
|
||||
pagination={criticalPagination}
|
||||
onPageChange={handleCriticalPageChangeWrapper}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<RecentActivitySection
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={effectiveIsAdmin}
|
||||
recentActivity={recentActivity}
|
||||
pagination={activityPagination}
|
||||
refreshing={refreshing}
|
||||
@ -242,7 +258,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
</div>
|
||||
|
||||
{/* ADMIN - Additional Analytics */}
|
||||
{isAdmin && kpis && (
|
||||
{effectiveIsAdmin && kpis && (
|
||||
<AdminAnalyticsSection
|
||||
kpis={kpis}
|
||||
upcomingDeadlines={upcomingDeadlines}
|
||||
@ -258,10 +274,10 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
)}
|
||||
|
||||
{/* NORMAL USER - Personal Metrics */}
|
||||
{!isAdmin && kpis && <UserMetricsSection kpis={kpis} />}
|
||||
{!effectiveIsAdmin && kpis && <UserMetricsSection kpis={kpis} />}
|
||||
|
||||
{/* Priority Distribution Report */}
|
||||
{isAdmin && priorityDistribution.length > 0 && (
|
||||
{effectiveIsAdmin && priorityDistribution.length > 0 && (
|
||||
<PriorityDistributionReport
|
||||
priorityDistribution={priorityDistribution}
|
||||
onNavigate={onNavigate}
|
||||
@ -269,7 +285,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
)}
|
||||
|
||||
{/* TAT Breach Report */}
|
||||
{isAdmin && breachedRequests.length > 0 && (
|
||||
{effectiveIsAdmin && breachedRequests.length > 0 && (
|
||||
<TATBreachReport
|
||||
breachedRequests={breachedRequests}
|
||||
pagination={criticalPagination}
|
||||
@ -285,7 +301,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
{/* Upcoming Deadlines / Workflow Aging */}
|
||||
{upcomingDeadlinesNotBreached.length > 0 && (
|
||||
<UpcomingDeadlinesSection
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={effectiveIsAdmin}
|
||||
upcomingDeadlines={upcomingDeadlinesNotBreached}
|
||||
pagination={deadlinesPagination}
|
||||
onPageChange={handleDeadlinesPageChangeWrapper}
|
||||
@ -294,12 +310,12 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
)}
|
||||
|
||||
{/* AI Remark Utilization Report (Admin Only) */}
|
||||
{isAdmin && aiRemarkUtilization && (
|
||||
{effectiveIsAdmin && aiRemarkUtilization && (
|
||||
<AIRemarkUtilizationReport aiRemarkUtilization={aiRemarkUtilization} />
|
||||
)}
|
||||
|
||||
{/* Approver Performance Report (Admin Only) */}
|
||||
{isAdmin && approverPerformance.length > 0 && (
|
||||
{effectiveIsAdmin && approverPerformance.length > 0 && (
|
||||
<ApproverPerformanceReport
|
||||
approverPerformance={approverPerformance}
|
||||
pagination={approverPagination}
|
||||
|
||||
@ -5,32 +5,91 @@
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Shield, Zap, Activity } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { User, Building2 } from 'lucide-react';
|
||||
import { QuickAction } from '../utils/dashboardNavigation';
|
||||
|
||||
interface DashboardHeroProps {
|
||||
isAdmin: boolean;
|
||||
effectiveIsAdmin: boolean;
|
||||
viewAsUser: boolean;
|
||||
onToggleView: (viewAsUser: boolean) => void;
|
||||
quickActions: QuickAction[];
|
||||
userDisplayName?: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
export function DashboardHero({ isAdmin, quickActions }: DashboardHeroProps) {
|
||||
export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) {
|
||||
// Get user's name for welcome message
|
||||
const userName = userDisplayName || userEmail?.split('@')[0] || 'User';
|
||||
|
||||
// Get current time for greeting
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
return (
|
||||
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
|
||||
|
||||
<CardContent className="relative z-10 p-4 sm:p-6 lg:p-12">
|
||||
{/* Toggle for admin to switch between admin and personal view - Top Right Corner */}
|
||||
{isAdmin && (
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20" data-testid="view-toggle">
|
||||
<div className="flex items-center gap-2 p-2 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 shadow-lg">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${
|
||||
!viewAsUser
|
||||
? 'bg-red-600/20 border border-red-600/50'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => onToggleView(false)}
|
||||
>
|
||||
<Building2 className={`w-3.5 h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
!viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
Org
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="view-toggle-switch"
|
||||
checked={viewAsUser}
|
||||
onCheckedChange={onToggleView}
|
||||
className="data-[state=checked]:bg-red-600 data-[state=unchecked]:bg-gray-600 shrink-0"
|
||||
data-testid="view-toggle-switch"
|
||||
/>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${
|
||||
viewAsUser
|
||||
? 'bg-red-600/20 border border-red-600/50'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => onToggleView(true)}
|
||||
>
|
||||
<User className={`w-3.5 h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
Personal
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6">
|
||||
<div className="text-white w-full lg:w-auto">
|
||||
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg" data-testid="hero-icon">
|
||||
<Shield className="w-6 h-6 sm:w-8 sm:h-8 text-slate-900" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
|
||||
{isAdmin ? 'Management Dashboard' : 'My Dashboard'}
|
||||
{getGreeting()}, {userName}!
|
||||
</h1>
|
||||
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
|
||||
{isAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}
|
||||
{effectiveIsAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,7 +111,7 @@ export function DashboardHero({ isAdmin, quickActions }: DashboardHeroProps) {
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="hidden lg:flex items-center gap-4" data-testid="hero-decorations">
|
||||
{/* <div className="hidden lg:flex items-center gap-4" data-testid="hero-decorations">
|
||||
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
|
||||
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
|
||||
<Zap className="w-8 h-8 text-yellow-400" />
|
||||
@ -61,7 +120,7 @@ export function DashboardHero({ isAdmin, quickActions }: DashboardHeroProps) {
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center">
|
||||
<Activity className="w-6 h-6 text-white/80" />
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -9,6 +9,8 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
|
||||
|
||||
interface UseDashboardDataOptions {
|
||||
isAdmin: boolean;
|
||||
viewAsUser?: boolean; // For admin to view as normal user
|
||||
userId?: string; // User ID for filtering when viewAsUser is true (not used directly, backend handles it)
|
||||
dateRange: DateRange;
|
||||
customStartDate?: Date;
|
||||
customEndDate?: Date;
|
||||
@ -22,6 +24,8 @@ interface UseDashboardDataOptions {
|
||||
|
||||
export function useDashboardData({
|
||||
isAdmin,
|
||||
viewAsUser = false,
|
||||
userId: _userId, // Prefixed with _ to indicate intentionally unused (backend handles userId from auth)
|
||||
dateRange,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
@ -52,10 +56,10 @@ export function useDashboardData({
|
||||
|
||||
// Fetch common data for all users
|
||||
const commonPromises = [
|
||||
dashboardService.getKPIs(dateRange, customStartDate, customEndDate),
|
||||
dashboardService.getRecentActivity(1, 10),
|
||||
dashboardService.getCriticalRequests(1, 10),
|
||||
dashboardService.getUpcomingDeadlines(1, 10)
|
||||
dashboardService.getKPIs(dateRange, customStartDate, customEndDate, viewAsUser),
|
||||
dashboardService.getRecentActivity(1, 10, viewAsUser),
|
||||
dashboardService.getCriticalRequests(1, 10, viewAsUser),
|
||||
dashboardService.getUpcomingDeadlines(1, 10, viewAsUser)
|
||||
];
|
||||
|
||||
// Fetch admin-only data if user is admin
|
||||
@ -124,12 +128,12 @@ export function useDashboardData({
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [isAdmin, dateRange, customStartDate, customEndDate]);
|
||||
}, [isAdmin, viewAsUser, dateRange, customStartDate, customEndDate]);
|
||||
|
||||
// Fetch individual data with pagination
|
||||
const fetchRecentActivities = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
const result = await dashboardService.getRecentActivity(page, 10);
|
||||
const result = await dashboardService.getRecentActivity(page, 10, viewAsUser);
|
||||
setRecentActivity(result.activities);
|
||||
paginationCallbacksRef.current.activity(
|
||||
result.pagination.currentPage,
|
||||
@ -139,11 +143,11 @@ export function useDashboardData({
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent activities:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [viewAsUser]);
|
||||
|
||||
const fetchCriticalRequests = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
const result = await dashboardService.getCriticalRequests(page, 10);
|
||||
const result = await dashboardService.getCriticalRequests(page, 10, viewAsUser);
|
||||
setCriticalRequests(result.criticalRequests);
|
||||
paginationCallbacksRef.current.critical(
|
||||
result.pagination.currentPage,
|
||||
@ -153,11 +157,11 @@ export function useDashboardData({
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch critical requests:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [viewAsUser]);
|
||||
|
||||
const fetchUpcomingDeadlines = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
const result = await dashboardService.getUpcomingDeadlines(page, 10);
|
||||
const result = await dashboardService.getUpcomingDeadlines(page, 10, viewAsUser);
|
||||
setUpcomingDeadlines(result.deadlines);
|
||||
paginationCallbacksRef.current.deadlines(
|
||||
result.pagination.currentPage,
|
||||
@ -167,7 +171,7 @@ export function useDashboardData({
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch upcoming deadlines:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [viewAsUser]);
|
||||
|
||||
const fetchApproverPerformance = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
|
||||
@ -11,6 +11,31 @@ import { MyRequest } from '../types/myRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
/**
|
||||
* Strip HTML tags and convert to plain text for card preview
|
||||
*/
|
||||
const stripHtmlTags = (html: string): string => {
|
||||
if (!html) return '';
|
||||
|
||||
// Check if we're in a browser environment
|
||||
if (typeof document === 'undefined') {
|
||||
// Fallback for SSR: use regex to strip HTML tags
|
||||
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Create a temporary div to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// Get text content (automatically strips HTML tags)
|
||||
let text = tempDiv.textContent || tempDiv.innerText || '';
|
||||
|
||||
// Clean up extra whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
interface RequestCardProps {
|
||||
request: MyRequest;
|
||||
index: number;
|
||||
@ -73,8 +98,8 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2" data-testid="request-description">
|
||||
{request.description}
|
||||
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2 leading-relaxed" data-testid="request-description">
|
||||
{stripHtmlTags(request.description || '') || 'No description provided'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
||||
<span className="truncate" data-testid="request-id-display">
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||
|
||||
@ -85,9 +86,10 @@ export function OverviewTab({
|
||||
<div>
|
||||
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
|
||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
|
||||
<p className="text-xs sm:text-sm text-gray-700 whitespace-pre-line leading-relaxed break-words">
|
||||
{request.description}
|
||||
</p>
|
||||
<FormattedDescription
|
||||
content={request.description || ''}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -173,9 +175,12 @@ export function OverviewTab({
|
||||
{request.claimDetails.requestDescription && (
|
||||
<div className="pt-4 border-t border-gray-300">
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
|
||||
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
|
||||
{request.claimDetails.requestDescription}
|
||||
</p>
|
||||
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
|
||||
<FormattedDescription
|
||||
content={request.claimDetails.requestDescription}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -194,7 +199,10 @@ export function OverviewTab({
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">{request.conclusionRemark}</p>
|
||||
<FormattedDescription
|
||||
content={request.conclusionRemark || ''}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{request.closureDate && (
|
||||
@ -264,20 +272,23 @@ export function OverviewTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
<RichTextEditor
|
||||
value={conclusionRemark}
|
||||
onChange={(e) => setConclusionRemark(e.target.value)}
|
||||
onChange={(html) => setConclusionRemark(html)}
|
||||
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
|
||||
className="text-sm resize-none"
|
||||
style={{ height: '160px' }}
|
||||
maxLength={2000}
|
||||
className="text-sm"
|
||||
minHeight="160px"
|
||||
data-testid="conclusion-remark-textarea"
|
||||
/>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-xs text-gray-500">This will be the final summary for this request</p>
|
||||
<p className="text-xs text-gray-500" data-testid="character-count">
|
||||
{conclusionRemark.length} / 2000 characters
|
||||
{/* Count text content length (strip HTML tags) */}
|
||||
{conclusionRemark ? conclusionRemark.replace(/<[^>]*>/g, '').length : 0} / 2000 characters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||
import { FileText, CheckCircle, XCircle, Clock, Loader2, Share2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import type { SummaryDetails } from '@/services/summaryApi';
|
||||
@ -90,7 +91,12 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
||||
)}
|
||||
</div>
|
||||
{summary.description && (
|
||||
<p className="text-gray-700 mb-4">{summary.description}</p>
|
||||
<div className="mb-4">
|
||||
<FormattedDescription
|
||||
content={summary.description}
|
||||
className="text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -192,7 +198,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{summary.closingRemarks || '—'}</p>
|
||||
{summary.closingRemarks ? (
|
||||
<FormattedDescription
|
||||
content={summary.closingRemarks}
|
||||
className="text-sm text-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-700">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,31 @@ import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import type { ConvertedRequest } from '../types/requests.types';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
/**
|
||||
* Strip HTML tags and convert to plain text for card preview
|
||||
*/
|
||||
const stripHtmlTags = (html: string): string => {
|
||||
if (!html) return '';
|
||||
|
||||
// Check if we're in a browser environment
|
||||
if (typeof document === 'undefined') {
|
||||
// Fallback for SSR: use regex to strip HTML tags
|
||||
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Create a temporary div to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// Get text content (automatically strips HTML tags)
|
||||
let text = tempDiv.textContent || tempDiv.innerText || '';
|
||||
|
||||
// Clean up extra whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
interface RequestCardProps {
|
||||
request: ConvertedRequest;
|
||||
index: number;
|
||||
@ -72,10 +97,10 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2"
|
||||
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2 leading-relaxed"
|
||||
data-testid="request-description"
|
||||
>
|
||||
{request.description}
|
||||
{stripHtmlTags(request.description || '') || 'No description provided'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
||||
<span className="truncate" data-testid="request-id-display">
|
||||
|
||||
@ -86,23 +86,23 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="min-h-screen bg-gray-50 p-3 sm:p-4 md:p-6 overflow-x-hidden">
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Shared Summaries</h1>
|
||||
<p className="text-sm text-gray-600">View summaries of closed requests shared with you</p>
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-1 sm:mb-2">Shared Summaries</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-600">View summaries of closed requests shared with you</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search by title, request number, or user..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -116,10 +116,10 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
|
||||
{/* Summaries List */}
|
||||
{!loading && filteredSummaries.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No shared summaries</h3>
|
||||
<p className="text-gray-600">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sm:p-12 text-center">
|
||||
<FileText className="h-8 w-8 sm:h-12 sm:w-12 text-gray-400 mx-auto mb-3 sm:mb-4" />
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-1 sm:mb-2">No shared summaries</h3>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
{searchTerm ? 'No summaries match your search.' : 'You haven\'t received any shared summaries yet.'}
|
||||
</p>
|
||||
</div>
|
||||
@ -127,7 +127,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
|
||||
{!loading && filteredSummaries.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-4 mb-6">
|
||||
<div className="grid gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
{filteredSummaries.map((summary) => (
|
||||
<div
|
||||
key={summary.sharedSummaryId}
|
||||
@ -136,46 +136,46 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
}`}
|
||||
onClick={() => handleViewSummary(summary.sharedSummaryId)}
|
||||
>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-3 sm:p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
{summary.isRead ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
<EyeOff className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
<Eye className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900 truncate flex-1 min-w-0">
|
||||
{summary.title}
|
||||
</h3>
|
||||
{!summary.isRead && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 break-words">
|
||||
Request: <span className="font-medium">{summary.requestNumber}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Initiator: {summary.initiatorName}</span>
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-start sm:items-center gap-2 sm:gap-3 md:gap-4 text-xs sm:text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<User className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="truncate">Initiator: <span className="font-medium">{summary.initiatorName}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Shared by: {summary.sharedByName}</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<User className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="truncate">Shared by: <span className="font-medium">{summary.sharedByName}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Calendar className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
Shared: {format(new Date(summary.sharedAt), 'MMM dd, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
{summary.viewedAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Eye className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
Viewed: {format(new Date(summary.viewedAt), 'MMM dd, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
@ -185,6 +185,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewSummary(summary.sharedSummaryId);
|
||||
@ -200,20 +201,21 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-4 bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div className="text-xs sm:text-sm text-gray-600 text-center sm:text-left">
|
||||
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} summaries
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-xs sm:text-sm text-gray-600 whitespace-nowrap">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
@ -221,6 +223,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||
import { Loader2, ArrowLeft, FileText, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { getSummaryDetails, markAsViewed, type SummaryDetails } from '@/services/summaryApi';
|
||||
import { format } from 'date-fns';
|
||||
@ -128,7 +129,12 @@ export function SharedSummaryDetail() {
|
||||
</Badge>
|
||||
</div>
|
||||
{summary.description && (
|
||||
<p className="text-gray-700 mb-4">{summary.description}</p>
|
||||
<div className="mb-4">
|
||||
<FormattedDescription
|
||||
content={summary.description}
|
||||
className="text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -229,7 +235,14 @@ export function SharedSummaryDetail() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{summary.closingRemarks || '—'}</p>
|
||||
{summary.closingRemarks ? (
|
||||
<FormattedDescription
|
||||
content={summary.closingRemarks}
|
||||
className="text-sm text-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-700">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -163,13 +163,16 @@ class DashboardService {
|
||||
/**
|
||||
* Get all KPI metrics
|
||||
*/
|
||||
async getKPIs(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DashboardKPIs> {
|
||||
async getKPIs(dateRange?: DateRange, startDate?: Date, endDate?: Date, viewAsUser?: boolean): Promise<DashboardKPIs> {
|
||||
try {
|
||||
const params: any = { dateRange };
|
||||
if (dateRange === 'custom' && startDate && endDate) {
|
||||
params.startDate = startDate.toISOString();
|
||||
params.endDate = endDate.toISOString();
|
||||
}
|
||||
if (viewAsUser) {
|
||||
params.viewAsUser = 'true';
|
||||
}
|
||||
const response = await apiClient.get('/dashboard/kpis', { params });
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
@ -296,7 +299,7 @@ class DashboardService {
|
||||
/**
|
||||
* Get recent activity feed with pagination
|
||||
*/
|
||||
async getRecentActivity(page: number = 1, limit: number = 10): Promise<{
|
||||
async getRecentActivity(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
|
||||
activities: RecentActivity[],
|
||||
pagination: {
|
||||
currentPage: number,
|
||||
@ -306,9 +309,11 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/activity/recent', {
|
||||
params: { page, limit }
|
||||
});
|
||||
const params: any = { page, limit };
|
||||
if (viewAsUser) {
|
||||
params.viewAsUser = 'true';
|
||||
}
|
||||
const response = await apiClient.get('/dashboard/activity/recent', { params });
|
||||
return {
|
||||
activities: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
@ -322,7 +327,7 @@ class DashboardService {
|
||||
/**
|
||||
* Get critical requests with pagination
|
||||
*/
|
||||
async getCriticalRequests(page: number = 1, limit: number = 10): Promise<{
|
||||
async getCriticalRequests(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
|
||||
criticalRequests: CriticalRequest[],
|
||||
pagination: {
|
||||
currentPage: number,
|
||||
@ -332,9 +337,11 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/requests/critical', {
|
||||
params: { page, limit }
|
||||
});
|
||||
const params: any = { page, limit };
|
||||
if (viewAsUser) {
|
||||
params.viewAsUser = 'true';
|
||||
}
|
||||
const response = await apiClient.get('/dashboard/requests/critical', { params });
|
||||
return {
|
||||
criticalRequests: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
@ -348,7 +355,7 @@ class DashboardService {
|
||||
/**
|
||||
* Get upcoming deadlines with pagination
|
||||
*/
|
||||
async getUpcomingDeadlines(page: number = 1, limit: number = 10): Promise<{
|
||||
async getUpcomingDeadlines(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
|
||||
deadlines: UpcomingDeadline[],
|
||||
pagination: {
|
||||
currentPage: number,
|
||||
@ -358,9 +365,11 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/deadlines/upcoming', {
|
||||
params: { page, limit }
|
||||
});
|
||||
const params: any = { page, limit };
|
||||
if (viewAsUser) {
|
||||
params.viewAsUser = 'true';
|
||||
}
|
||||
const response = await apiClient.get('/dashboard/deadlines/upcoming', { params });
|
||||
return {
|
||||
deadlines: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
|
||||
Loading…
Reference in New Issue
Block a user