formatedd description implemente along with request number format chnged

This commit is contained in:
laxmanhalaki 2025-11-26 18:23:02 +05:30
parent 2681631d5d
commit 2161cc59ca
16 changed files with 953 additions and 119 deletions

View 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 }}
/>
);
}

View File

View 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>
);
}

View File

@ -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>

View File

@ -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">

View File

@ -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);

View File

@ -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}

View File

@ -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>

View File

@ -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 {

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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