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 { motion } from 'framer-motion';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -70,13 +70,16 @@ export function BasicInformationStep({
|
|||||||
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
|
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
Explain what you need approval for, why it's needed, and any relevant background information.
|
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>
|
</p>
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
id="description"
|
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."
|
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"
|
className="min-h-[120px] text-base border-2 border-gray-300 focus-within:border-blue-500 bg-white shadow-sm"
|
||||||
value={formData.description}
|
minHeight="120px"
|
||||||
onChange={(e) => updateFormData('description', e.target.value)}
|
|
||||||
data-testid="basic-information-description-textarea"
|
data-testid="basic-information-description-textarea"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { CheckCircle, Rocket, FileText, Users, Eye, Upload, Flame, Target, TrendingUp, DollarSign } from 'lucide-react';
|
||||||
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
|
|
||||||
@ -103,9 +104,9 @@ export function ReviewSubmitStep({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div data-testid="review-submit-basic-info-description">
|
<div data-testid="review-submit-basic-info-description">
|
||||||
<Label className="font-semibold">Description</Label>
|
<Label className="font-semibold">Description</Label>
|
||||||
<p className="text-sm text-gray-700 mt-1 p-3 bg-gray-50 rounded-lg border">
|
<div className="mt-1 p-3 bg-gray-50 rounded-lg border">
|
||||||
{formData.description}
|
<FormattedDescription content={formData.description || ''} />
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formData.amount && (
|
{formData.amount && (
|
||||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200" data-testid="review-submit-basic-info-financial">
|
<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
|
* 6. Handle errors with user-friendly messages
|
||||||
*/
|
*/
|
||||||
const handleFinalizeConclusion = async () => {
|
const handleFinalizeConclusion = async () => {
|
||||||
// Validation: Ensure conclusion is not empty
|
// Validation: Ensure conclusion is not empty (strip HTML tags for validation)
|
||||||
if (!conclusionRemark.trim()) {
|
const textContent = conclusionRemark.replace(/<[^>]*>/g, '').trim();
|
||||||
|
if (!textContent) {
|
||||||
setActionStatus?.({
|
setActionStatus?.({
|
||||||
success: false,
|
success: false,
|
||||||
title: 'Validation Error',
|
title: 'Validation Error',
|
||||||
@ -148,6 +149,17 @@ export function useConclusionRemark(
|
|||||||
setShowActionStatusModal?.(true);
|
setShowActionStatusModal?.(true);
|
||||||
return;
|
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 {
|
try {
|
||||||
setConclusionSubmitting(true);
|
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 { RefreshCw } from 'lucide-react';
|
||||||
import { type DateRange } from '@/services/dashboard.service';
|
import { type DateRange } from '@/services/dashboard.service';
|
||||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||||
@ -39,6 +39,12 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
|
|
||||||
// Determine user role
|
// Determine user role
|
||||||
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
|
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
|
// Filters
|
||||||
const filters = useDashboardFilters();
|
const filters = useDashboardFilters();
|
||||||
@ -63,7 +69,9 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
|
|
||||||
// Data fetching
|
// Data fetching
|
||||||
const dashboardData = useDashboardData({
|
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,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
@ -143,7 +151,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
fetchDashboardData(false);
|
fetchDashboardData(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dateRange, customStartDate, customEndDate]);
|
}, [dateRange, customStartDate, customEndDate, viewAsUser]);
|
||||||
|
|
||||||
// Quick actions
|
// Quick actions
|
||||||
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate), [isAdmin, onNewRequest, onNavigate]);
|
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate), [isAdmin, onNewRequest, onNavigate]);
|
||||||
@ -176,10 +184,18 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto p-3 sm:p-4" data-testid="dashboard">
|
<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
|
<DashboardFiltersBar
|
||||||
isAdmin={isAdmin}
|
isAdmin={effectiveIsAdmin}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
customStartDate={customStartDate}
|
customStartDate={customStartDate}
|
||||||
customEndDate={customEndDate}
|
customEndDate={customEndDate}
|
||||||
@ -195,7 +211,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* KPI Cards Section */}
|
{/* KPI Cards Section */}
|
||||||
{isAdmin ? (
|
{effectiveIsAdmin ? (
|
||||||
<AdminKPICards
|
<AdminKPICards
|
||||||
kpis={kpis}
|
kpis={kpis}
|
||||||
priorityDistribution={priorityDistribution}
|
priorityDistribution={priorityDistribution}
|
||||||
@ -221,14 +237,14 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
{/* Alerts and Activity Section */}
|
{/* 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">
|
<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
|
<CriticalAlertsSection
|
||||||
isAdmin={isAdmin}
|
isAdmin={effectiveIsAdmin}
|
||||||
breachedRequests={breachedRequests}
|
breachedRequests={breachedRequests}
|
||||||
pagination={criticalPagination}
|
pagination={criticalPagination}
|
||||||
onPageChange={handleCriticalPageChangeWrapper}
|
onPageChange={handleCriticalPageChangeWrapper}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
<RecentActivitySection
|
<RecentActivitySection
|
||||||
isAdmin={isAdmin}
|
isAdmin={effectiveIsAdmin}
|
||||||
recentActivity={recentActivity}
|
recentActivity={recentActivity}
|
||||||
pagination={activityPagination}
|
pagination={activityPagination}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@ -242,7 +258,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ADMIN - Additional Analytics */}
|
{/* ADMIN - Additional Analytics */}
|
||||||
{isAdmin && kpis && (
|
{effectiveIsAdmin && kpis && (
|
||||||
<AdminAnalyticsSection
|
<AdminAnalyticsSection
|
||||||
kpis={kpis}
|
kpis={kpis}
|
||||||
upcomingDeadlines={upcomingDeadlines}
|
upcomingDeadlines={upcomingDeadlines}
|
||||||
@ -258,10 +274,10 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NORMAL USER - Personal Metrics */}
|
{/* NORMAL USER - Personal Metrics */}
|
||||||
{!isAdmin && kpis && <UserMetricsSection kpis={kpis} />}
|
{!effectiveIsAdmin && kpis && <UserMetricsSection kpis={kpis} />}
|
||||||
|
|
||||||
{/* Priority Distribution Report */}
|
{/* Priority Distribution Report */}
|
||||||
{isAdmin && priorityDistribution.length > 0 && (
|
{effectiveIsAdmin && priorityDistribution.length > 0 && (
|
||||||
<PriorityDistributionReport
|
<PriorityDistributionReport
|
||||||
priorityDistribution={priorityDistribution}
|
priorityDistribution={priorityDistribution}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
@ -269,7 +285,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAT Breach Report */}
|
{/* TAT Breach Report */}
|
||||||
{isAdmin && breachedRequests.length > 0 && (
|
{effectiveIsAdmin && breachedRequests.length > 0 && (
|
||||||
<TATBreachReport
|
<TATBreachReport
|
||||||
breachedRequests={breachedRequests}
|
breachedRequests={breachedRequests}
|
||||||
pagination={criticalPagination}
|
pagination={criticalPagination}
|
||||||
@ -285,7 +301,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
{/* Upcoming Deadlines / Workflow Aging */}
|
{/* Upcoming Deadlines / Workflow Aging */}
|
||||||
{upcomingDeadlinesNotBreached.length > 0 && (
|
{upcomingDeadlinesNotBreached.length > 0 && (
|
||||||
<UpcomingDeadlinesSection
|
<UpcomingDeadlinesSection
|
||||||
isAdmin={isAdmin}
|
isAdmin={effectiveIsAdmin}
|
||||||
upcomingDeadlines={upcomingDeadlinesNotBreached}
|
upcomingDeadlines={upcomingDeadlinesNotBreached}
|
||||||
pagination={deadlinesPagination}
|
pagination={deadlinesPagination}
|
||||||
onPageChange={handleDeadlinesPageChangeWrapper}
|
onPageChange={handleDeadlinesPageChangeWrapper}
|
||||||
@ -294,12 +310,12 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Remark Utilization Report (Admin Only) */}
|
{/* AI Remark Utilization Report (Admin Only) */}
|
||||||
{isAdmin && aiRemarkUtilization && (
|
{effectiveIsAdmin && aiRemarkUtilization && (
|
||||||
<AIRemarkUtilizationReport aiRemarkUtilization={aiRemarkUtilization} />
|
<AIRemarkUtilizationReport aiRemarkUtilization={aiRemarkUtilization} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Approver Performance Report (Admin Only) */}
|
{/* Approver Performance Report (Admin Only) */}
|
||||||
{isAdmin && approverPerformance.length > 0 && (
|
{effectiveIsAdmin && approverPerformance.length > 0 && (
|
||||||
<ApproverPerformanceReport
|
<ApproverPerformanceReport
|
||||||
approverPerformance={approverPerformance}
|
approverPerformance={approverPerformance}
|
||||||
pagination={approverPagination}
|
pagination={approverPagination}
|
||||||
|
|||||||
@ -5,32 +5,91 @@
|
|||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { QuickAction } from '../utils/dashboardNavigation';
|
||||||
|
|
||||||
interface DashboardHeroProps {
|
interface DashboardHeroProps {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
effectiveIsAdmin: boolean;
|
||||||
|
viewAsUser: boolean;
|
||||||
|
onToggleView: (viewAsUser: boolean) => void;
|
||||||
quickActions: QuickAction[];
|
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 (
|
return (
|
||||||
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
|
<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>
|
<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">
|
<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="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="text-white w-full lg:w-auto">
|
||||||
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
|
<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>
|
</h1>
|
||||||
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -52,7 +111,7 @@ export function DashboardHero({ isAdmin, quickActions }: DashboardHeroProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative Elements */}
|
{/* 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-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">
|
<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" />
|
<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">
|
<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" />
|
<Activity className="w-6 h-6 text-white/80" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
|
|||||||
|
|
||||||
interface UseDashboardDataOptions {
|
interface UseDashboardDataOptions {
|
||||||
isAdmin: boolean;
|
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;
|
dateRange: DateRange;
|
||||||
customStartDate?: Date;
|
customStartDate?: Date;
|
||||||
customEndDate?: Date;
|
customEndDate?: Date;
|
||||||
@ -22,6 +24,8 @@ interface UseDashboardDataOptions {
|
|||||||
|
|
||||||
export function useDashboardData({
|
export function useDashboardData({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
viewAsUser = false,
|
||||||
|
userId: _userId, // Prefixed with _ to indicate intentionally unused (backend handles userId from auth)
|
||||||
dateRange,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
@ -52,10 +56,10 @@ export function useDashboardData({
|
|||||||
|
|
||||||
// Fetch common data for all users
|
// Fetch common data for all users
|
||||||
const commonPromises = [
|
const commonPromises = [
|
||||||
dashboardService.getKPIs(dateRange, customStartDate, customEndDate),
|
dashboardService.getKPIs(dateRange, customStartDate, customEndDate, viewAsUser),
|
||||||
dashboardService.getRecentActivity(1, 10),
|
dashboardService.getRecentActivity(1, 10, viewAsUser),
|
||||||
dashboardService.getCriticalRequests(1, 10),
|
dashboardService.getCriticalRequests(1, 10, viewAsUser),
|
||||||
dashboardService.getUpcomingDeadlines(1, 10)
|
dashboardService.getUpcomingDeadlines(1, 10, viewAsUser)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fetch admin-only data if user is admin
|
// Fetch admin-only data if user is admin
|
||||||
@ -124,12 +128,12 @@ export function useDashboardData({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [isAdmin, dateRange, customStartDate, customEndDate]);
|
}, [isAdmin, viewAsUser, dateRange, customStartDate, customEndDate]);
|
||||||
|
|
||||||
// Fetch individual data with pagination
|
// Fetch individual data with pagination
|
||||||
const fetchRecentActivities = useCallback(async (page: number = 1) => {
|
const fetchRecentActivities = useCallback(async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
const result = await dashboardService.getRecentActivity(page, 10);
|
const result = await dashboardService.getRecentActivity(page, 10, viewAsUser);
|
||||||
setRecentActivity(result.activities);
|
setRecentActivity(result.activities);
|
||||||
paginationCallbacksRef.current.activity(
|
paginationCallbacksRef.current.activity(
|
||||||
result.pagination.currentPage,
|
result.pagination.currentPage,
|
||||||
@ -139,11 +143,11 @@ export function useDashboardData({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch recent activities:', error);
|
console.error('Failed to fetch recent activities:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [viewAsUser]);
|
||||||
|
|
||||||
const fetchCriticalRequests = useCallback(async (page: number = 1) => {
|
const fetchCriticalRequests = useCallback(async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
const result = await dashboardService.getCriticalRequests(page, 10);
|
const result = await dashboardService.getCriticalRequests(page, 10, viewAsUser);
|
||||||
setCriticalRequests(result.criticalRequests);
|
setCriticalRequests(result.criticalRequests);
|
||||||
paginationCallbacksRef.current.critical(
|
paginationCallbacksRef.current.critical(
|
||||||
result.pagination.currentPage,
|
result.pagination.currentPage,
|
||||||
@ -153,11 +157,11 @@ export function useDashboardData({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch critical requests:', error);
|
console.error('Failed to fetch critical requests:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [viewAsUser]);
|
||||||
|
|
||||||
const fetchUpcomingDeadlines = useCallback(async (page: number = 1) => {
|
const fetchUpcomingDeadlines = useCallback(async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
const result = await dashboardService.getUpcomingDeadlines(page, 10);
|
const result = await dashboardService.getUpcomingDeadlines(page, 10, viewAsUser);
|
||||||
setUpcomingDeadlines(result.deadlines);
|
setUpcomingDeadlines(result.deadlines);
|
||||||
paginationCallbacksRef.current.deadlines(
|
paginationCallbacksRef.current.deadlines(
|
||||||
result.pagination.currentPage,
|
result.pagination.currentPage,
|
||||||
@ -167,7 +171,7 @@ export function useDashboardData({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch upcoming deadlines:', error);
|
console.error('Failed to fetch upcoming deadlines:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [viewAsUser]);
|
||||||
|
|
||||||
const fetchApproverPerformance = useCallback(async (page: number = 1) => {
|
const fetchApproverPerformance = useCallback(async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -11,6 +11,31 @@ import { MyRequest } from '../types/myRequests.types';
|
|||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
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 {
|
interface RequestCardProps {
|
||||||
request: MyRequest;
|
request: MyRequest;
|
||||||
index: number;
|
index: number;
|
||||||
@ -73,8 +98,8 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2" data-testid="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">
|
||||||
{request.description}
|
{stripHtmlTags(request.description || '') || 'No description provided'}
|
||||||
</p>
|
</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">
|
<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">
|
<span className="truncate" data-testid="request-id-display">
|
||||||
|
|||||||
@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
|
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
@ -85,9 +86,10 @@ export function OverviewTab({
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
|
<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">
|
<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">
|
<FormattedDescription
|
||||||
{request.description}
|
content={request.description || ''}
|
||||||
</p>
|
className="text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -173,9 +175,12 @@ export function OverviewTab({
|
|||||||
{request.claimDetails.requestDescription && (
|
{request.claimDetails.requestDescription && (
|
||||||
<div className="pt-4 border-t border-gray-300">
|
<div className="pt-4 border-t border-gray-300">
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
|
<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">
|
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
|
||||||
{request.claimDetails.requestDescription}
|
<FormattedDescription
|
||||||
</p>
|
content={request.claimDetails.requestDescription}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -194,7 +199,10 @@ export function OverviewTab({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-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>
|
</div>
|
||||||
|
|
||||||
{request.closureDate && (
|
{request.closureDate && (
|
||||||
@ -264,20 +272,23 @@ export function OverviewTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
value={conclusionRemark}
|
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..."
|
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
|
||||||
className="text-sm resize-none"
|
className="text-sm"
|
||||||
style={{ height: '160px' }}
|
minHeight="160px"
|
||||||
maxLength={2000}
|
|
||||||
data-testid="conclusion-remark-textarea"
|
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">
|
<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">This will be the final summary for this request</p>
|
||||||
<p className="text-xs text-gray-500" data-testid="character-count">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { FileText, CheckCircle, XCircle, Clock, Loader2, Share2 } from 'lucide-react';
|
import { FileText, CheckCircle, XCircle, Clock, Loader2, Share2 } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import type { SummaryDetails } from '@/services/summaryApi';
|
import type { SummaryDetails } from '@/services/summaryApi';
|
||||||
@ -90,7 +91,12 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{summary.description && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@ -192,7 +198,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,6 +10,31 @@ import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
|||||||
import type { ConvertedRequest } from '../types/requests.types';
|
import type { ConvertedRequest } from '../types/requests.types';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
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 {
|
interface RequestCardProps {
|
||||||
request: ConvertedRequest;
|
request: ConvertedRequest;
|
||||||
index: number;
|
index: number;
|
||||||
@ -72,10 +97,10 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<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"
|
data-testid="request-description"
|
||||||
>
|
>
|
||||||
{request.description}
|
{stripHtmlTags(request.description || '') || 'No description provided'}
|
||||||
</p>
|
</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">
|
<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">
|
<span className="truncate" data-testid="request-id-display">
|
||||||
|
|||||||
@ -86,23 +86,23 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
<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">
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Shared Summaries</h1>
|
<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-sm text-gray-600">View summaries of closed requests shared with you</p>
|
<p className="text-xs sm:text-sm text-gray-600">View summaries of closed requests shared with you</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by title, request number, or user..."
|
placeholder="Search by title, request number, or user..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10 text-sm sm:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,10 +116,10 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
|
|
||||||
{/* Summaries List */}
|
{/* Summaries List */}
|
||||||
{!loading && filteredSummaries.length === 0 && (
|
{!loading && filteredSummaries.length === 0 && (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sm:p-12 text-center">
|
||||||
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<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-lg font-semibold text-gray-900 mb-2">No shared summaries</h3>
|
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-1 sm:mb-2">No shared summaries</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
{searchTerm ? 'No summaries match your search.' : 'You haven\'t received any shared summaries yet.'}
|
{searchTerm ? 'No summaries match your search.' : 'You haven\'t received any shared summaries yet.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -127,7 +127,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
|
|
||||||
{!loading && filteredSummaries.length > 0 && (
|
{!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) => (
|
{filteredSummaries.map((summary) => (
|
||||||
<div
|
<div
|
||||||
key={summary.sharedSummaryId}
|
key={summary.sharedSummaryId}
|
||||||
@ -136,46 +136,46 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => handleViewSummary(summary.sharedSummaryId)}
|
onClick={() => handleViewSummary(summary.sharedSummaryId)}
|
||||||
>
|
>
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-3 sm:p-4 md:p-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<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">
|
<div className="flex-1 min-w-0 w-full sm:w-auto">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
{summary.isRead ? (
|
{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}
|
{summary.title}
|
||||||
</h3>
|
</h3>
|
||||||
{!summary.isRead && (
|
{!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
|
New
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
Request: <span className="font-medium">{summary.requestNumber}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
<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">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||||
<span>Initiator: {summary.initiatorName}</span>
|
<span className="truncate">Initiator: <span className="font-medium">{summary.initiatorName}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||||
<span>Shared by: {summary.sharedByName}</span>
|
<span className="truncate">Shared by: <span className="font-medium">{summary.sharedByName}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
Shared: {format(new Date(summary.sharedAt), 'MMM dd, yyyy HH:mm')}
|
Shared: {format(new Date(summary.sharedAt), 'MMM dd, yyyy HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{summary.viewedAt && (
|
{summary.viewedAt && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
Viewed: {format(new Date(summary.viewedAt), 'MMM dd, yyyy HH:mm')}
|
Viewed: {format(new Date(summary.viewedAt), 'MMM dd, yyyy HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -185,6 +185,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="w-full sm:w-auto flex-shrink-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleViewSummary(summary.sharedSummaryId);
|
handleViewSummary(summary.sharedSummaryId);
|
||||||
@ -200,20 +201,21 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<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-sm text-gray-600">
|
<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
|
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} summaries
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-xs sm:text-sm text-gray-600 whitespace-nowrap">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -221,6 +223,7 @@ export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { Loader2, ArrowLeft, FileText, CheckCircle, XCircle, Clock } from 'lucide-react';
|
import { Loader2, ArrowLeft, FileText, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||||
import { getSummaryDetails, markAsViewed, type SummaryDetails } from '@/services/summaryApi';
|
import { getSummaryDetails, markAsViewed, type SummaryDetails } from '@/services/summaryApi';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -128,7 +129,12 @@ export function SharedSummaryDetail() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{summary.description && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@ -229,7 +235,14 @@ export function SharedSummaryDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -163,13 +163,16 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get all KPI metrics
|
* 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 {
|
try {
|
||||||
const params: any = { dateRange };
|
const params: any = { dateRange };
|
||||||
if (dateRange === 'custom' && startDate && endDate) {
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
params.startDate = startDate.toISOString();
|
params.startDate = startDate.toISOString();
|
||||||
params.endDate = endDate.toISOString();
|
params.endDate = endDate.toISOString();
|
||||||
}
|
}
|
||||||
|
if (viewAsUser) {
|
||||||
|
params.viewAsUser = 'true';
|
||||||
|
}
|
||||||
const response = await apiClient.get('/dashboard/kpis', { params });
|
const response = await apiClient.get('/dashboard/kpis', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -296,7 +299,7 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get recent activity feed with pagination
|
* 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[],
|
activities: RecentActivity[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
@ -306,9 +309,11 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/activity/recent', {
|
const params: any = { page, limit };
|
||||||
params: { page, limit }
|
if (viewAsUser) {
|
||||||
});
|
params.viewAsUser = 'true';
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/activity/recent', { params });
|
||||||
return {
|
return {
|
||||||
activities: response.data.data,
|
activities: response.data.data,
|
||||||
pagination: response.data.pagination
|
pagination: response.data.pagination
|
||||||
@ -322,7 +327,7 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get critical requests with pagination
|
* 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[],
|
criticalRequests: CriticalRequest[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
@ -332,9 +337,11 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/requests/critical', {
|
const params: any = { page, limit };
|
||||||
params: { page, limit }
|
if (viewAsUser) {
|
||||||
});
|
params.viewAsUser = 'true';
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/requests/critical', { params });
|
||||||
return {
|
return {
|
||||||
criticalRequests: response.data.data,
|
criticalRequests: response.data.data,
|
||||||
pagination: response.data.pagination
|
pagination: response.data.pagination
|
||||||
@ -348,7 +355,7 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get upcoming deadlines with pagination
|
* 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[],
|
deadlines: UpcomingDeadline[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
@ -358,9 +365,11 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/deadlines/upcoming', {
|
const params: any = { page, limit };
|
||||||
params: { page, limit }
|
if (viewAsUser) {
|
||||||
});
|
params.viewAsUser = 'true';
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/deadlines/upcoming', { params });
|
||||||
return {
|
return {
|
||||||
deadlines: response.data.data,
|
deadlines: response.data.data,
|
||||||
pagination: response.data.pagination
|
pagination: response.data.pagination
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user