enhanced questionnarier and request detail screen

This commit is contained in:
laxmanhalaki 2026-02-17 20:42:28 +05:30
parent 8ef092f723
commit 0ff5412d04
6 changed files with 265 additions and 77 deletions

View File

@ -39,6 +39,18 @@ export const API = {
getPublicQuestionnaire: (appId: string) => axios.get(`http://localhost:5000/api/questionnaire/public/${appId}`), // Direct axios to bypass interceptors if client has auth
submitPublicResponse: (data: any) => axios.post('http://localhost:5000/api/questionnaire/public/submit', data),
// Assessment & Interviews
getAiSummary: (appId: string) => client.get(`/assessment/ai-summary/${appId}`),
scheduleInterview: (data: any) => client.post('/assessment/interviews', data),
updateInterview: (id: string, data: any) => client.put(`/assessment/interviews/${id}`, data),
submitEvaluation: (id: string, data: any) => client.post(`/assessment/interviews/${id}/evaluation`, data),
// Collaboration & Participants
getWorknotes: (requestId: string, requestType: string) => client.get('/collaboration/worknotes', { params: { requestId, requestType } }),
addWorknote: (data: any) => client.post('/collaboration/worknotes', data),
addParticipant: (data: any) => client.post('/collaboration/participants', data),
removeParticipant: (id: string) => client.delete(`/collaboration/participants/${id}`),
// User management routes
getUsers: () => client.get('/admin/users'),
createUser: (data: any) => client.post('/admin/users', data),

View File

@ -37,7 +37,7 @@ import { Progress } from '../ui/progress';
import { Textarea } from '../ui/textarea';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from '../ui/dialog';
import { ScrollArea } from '../ui/scroll-area';
import {
Table,
@ -152,6 +152,7 @@ export function ApplicationDetails() {
loaDate: getStageDate('LOA'),
eorCompleteDate: getStageDate('EOR Complete'),
inaugurationDate: getStageDate('Inauguration'),
participants: data.participants || [],
};
setApplication(mappedApp);
} catch (error) {
@ -175,6 +176,7 @@ export function ApplicationDetails() {
const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false);
const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false);
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [interviewMode, setInterviewMode] = useState('virtual');
const [approvalRemark, setApprovalRemark] = useState('');
@ -184,6 +186,25 @@ export function ApplicationDetails() {
'architectural-work': true,
'statutory-documents': true
});
const [users, setUsers] = useState<any[]>([]);
const [selectedUser, setSelectedUser] = useState<string>('');
const [participantType, setParticipantType] = useState<string>('contributor');
const [interviewDate, setInterviewDate] = useState('');
const [interviewType, setInterviewType] = useState('level1');
const [meetingLink, setMeetingLink] = useState('');
const [location, setLocation] = useState('');
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await onboardingService.getUsers();
setUsers(response || []);
} catch (error) {
console.error('Failed to fetch users', error);
}
};
fetchUsers();
}, []);
if (loading) {
return <div>Loading application details...</div>;
@ -575,6 +596,50 @@ export function ApplicationDetails() {
setWorkNote('');
};
const handleAddParticipant = async () => {
if (!selectedUser) {
alert('Please select a user');
return;
}
try {
await onboardingService.addParticipant({
requestId: applicationId,
requestType: 'application',
userId: selectedUser,
participantType
});
alert('User assigned successfully!');
// Refresh application data
const data = await onboardingService.getApplicationById(applicationId);
setApplication({ ...application, participants: data.participants || [] });
setSelectedUser('');
setShowAssignModal(false);
} catch (error) {
alert('Failed to assign user');
}
};
const handleScheduleInterview = async () => {
if (!interviewDate) {
alert('Please select date and time');
return;
}
try {
await onboardingService.scheduleInterview({
applicationId,
level: interviewType,
scheduledAt: interviewDate,
type: interviewMode,
location: interviewMode === 'virtual' ? meetingLink : location,
participants: [] // Optional: add multi-participant support if needed
});
alert('Interview scheduled successfully!');
setShowScheduleModal(false);
} catch (error) {
alert('Failed to schedule interview');
}
};
// If Work Notes page is open, show that instead
if (showWorkNotesPage) {
return (
@ -584,6 +649,7 @@ export function ApplicationDetails() {
registrationNumber={application.registrationNumber}
onBack={() => setShowWorkNotesPage(false)}
initialNotes={mockWorkNotes}
participants={application.participants}
/>
);
}
@ -1232,10 +1298,55 @@ export function ApplicationDetails() {
</>
)}
<Button variant="outline" className="w-full">
<User className="w-4 h-4 mr-2" />
Assign User
</Button>
<Dialog open={showAssignModal} onOpenChange={setShowAssignModal}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full">
<User className="w-4 h-4 mr-2" />
Assign User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign User to Application</DialogTitle>
<DialogDescription>
Select a user and their role for this application.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Select User</Label>
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Search users..." />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.fullName} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Assignment Role</Label>
<Select value={participantType} onValueChange={setParticipantType}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="contributor">Contributor</SelectItem>
<SelectItem value="reviewer">Reviewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full bg-amber-600 hover:bg-amber-700" onClick={handleAddParticipant}>
Confirm Assignment
</Button>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
)}
@ -1411,7 +1522,7 @@ export function ApplicationDetails() {
<div className="space-y-4">
<div>
<Label>Interview Type</Label>
<Select>
<Select value={interviewType} onValueChange={setInterviewType}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select interview type" />
</SelectTrigger>
@ -1436,22 +1547,33 @@ export function ApplicationDetails() {
</div>
<div>
<Label>Date & Time</Label>
<Input type="datetime-local" className="mt-2" />
</div>
<div>
<Label>Participants</Label>
<Input placeholder="Enter participant emails (comma-separated)" className="mt-2" />
<Input
type="datetime-local"
className="mt-2"
value={interviewDate}
onChange={(e) => setInterviewDate(e.target.value)}
/>
</div>
{interviewMode === 'virtual' && (
<div>
<Label>Meeting Link</Label>
<Input placeholder="https://meet.google.com/..." className="mt-2" />
<Input
placeholder="https://meet.google.com/..."
className="mt-2"
value={meetingLink}
onChange={(e) => setMeetingLink(e.target.value)}
/>
</div>
)}
{interviewMode === 'physical' && (
<div>
<Label>Location</Label>
<Input placeholder="Enter interview location address" className="mt-2" />
<Input
placeholder="Enter interview location address"
className="mt-2"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
)}
<div className="flex gap-3">
@ -1464,10 +1586,7 @@ export function ApplicationDetails() {
</Button>
<Button
className="flex-1 bg-amber-600 hover:bg-amber-700"
onClick={() => {
alert('Interview scheduled! Calendar invites will be sent.');
setShowScheduleModal(false);
}}
onClick={handleScheduleInterview}
>
Schedule
</Button>

View File

@ -4,15 +4,15 @@ import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Badge } from '../ui/badge';
import {
ArrowLeft,
Send,
Paperclip,
import {
ArrowLeft,
Send,
Paperclip,
Smile,
Image as ImageIcon,
MessageSquare
} from 'lucide-react';
import { WorkNote } from '../../lib/mock-data';
import { WorkNote, Participant } from '../../lib/mock-data';
interface WorkNotesPageProps {
applicationId: string;
@ -20,20 +20,23 @@ interface WorkNotesPageProps {
registrationNumber: string;
onBack: () => void;
initialNotes?: WorkNote[];
participants?: Participant[];
}
interface Participant {
// This interface defines the structure for participants displayed in the UI
interface ParticipantUI {
name: string;
initials: string;
color: string;
}
export function WorkNotesPage({
applicationId,
export function WorkNotesPage({
applicationId,
applicationName,
registrationNumber,
onBack,
initialNotes = []
initialNotes = [],
participants: externalParticipants = []
}: WorkNotesPageProps) {
const [notes, setNotes] = useState<WorkNote[]>(initialNotes);
const [message, setMessage] = useState('');
@ -43,13 +46,40 @@ export function WorkNotesPage({
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Mock participants for @mentions
const participants: Participant[] = [
{ name: 'Sarah Chen', initials: 'SC', color: 'bg-green-600' },
{ name: 'Lisa Wong', initials: 'LW', color: 'bg-blue-600' },
{ name: 'Mark Johnson', initials: 'MJ', color: 'bg-purple-600' },
{ name: 'Anjali Sharma', initials: 'AS', color: 'bg-amber-600' },
];
const getInitials = (name: string) => {
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.substring(0, 2);
};
const getAvatarColor = (name: string) => {
const colors = [
'bg-green-600',
'bg-blue-600',
'bg-purple-600',
'bg-amber-600',
'bg-pink-600',
'bg-indigo-600',
'bg-teal-600',
];
const index = name.length % colors.length;
return colors[index];
};
// Map backend participants to the UI sub-format
const participantsList: ParticipantUI[] = externalParticipants.map(p => ({
name: p.user?.name || 'Unknown User',
initials: getInitials(p.user?.name || 'U'),
color: getAvatarColor(p.user?.name || 'U')
}));
// Fallback to current user if no participants
if (participantsList.length === 0) {
participantsList.push({ name: 'System User', initials: 'SU', color: 'bg-slate-600' });
}
// Scroll to bottom when new messages arrive
useEffect(() => {
@ -61,14 +91,14 @@ export function WorkNotesPage({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart || 0;
setMessage(value);
setCursorPosition(cursorPos);
// Check if user is typing @ mention
const textBeforeCursor = value.substring(0, cursorPos);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1 && lastAtSymbol === textBeforeCursor.length - 1) {
setShowMentionSuggestions(true);
setMentionQuery('');
@ -89,12 +119,12 @@ export function WorkNotesPage({
const textBeforeCursor = message.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const textAfterCursor = message.substring(cursorPosition);
const newMessage =
message.substring(0, lastAtSymbol) +
`@${name} ` +
const newMessage =
message.substring(0, lastAtSymbol) +
`@${name} ` +
textAfterCursor;
setMessage(newMessage);
setShowMentionSuggestions(false);
inputRef.current?.focus();
@ -139,7 +169,7 @@ export function WorkNotesPage({
const renderMessageWithMentions = (text: string) => {
const parts = text.split(/(@\w+\s*\w*)/g);
return parts.map((part, index) => {
if (part.startsWith('@')) {
return (
@ -152,30 +182,8 @@ export function WorkNotesPage({
});
};
const getInitials = (name: string) => {
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.substring(0, 2);
};
const getAvatarColor = (name: string) => {
const colors = [
'bg-green-600',
'bg-blue-600',
'bg-purple-600',
'bg-amber-600',
'bg-pink-600',
'bg-indigo-600',
'bg-teal-600',
];
const index = name.length % colors.length;
return colors[index];
};
const filteredParticipants = participants.filter(p =>
const filteredParticipants = participantsList.filter(p =>
p.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
@ -185,19 +193,19 @@ export function WorkNotesPage({
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="hover:bg-slate-100"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="w-12 h-12 bg-purple-600 rounded-lg flex items-center justify-center">
<MessageSquare className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-slate-900">Work Notes</h1>
<div className="flex items-center gap-2 text-slate-600">
@ -210,8 +218,8 @@ export function WorkNotesPage({
{/* Participant Avatars */}
<div className="flex items-center -space-x-2">
{participants.slice(0, 3).map((participant, index) => (
<Avatar
{participantsList.slice(0, 3).map((participant, index) => (
<Avatar
key={index}
className="w-8 h-8 border-2 border-white"
>
@ -220,9 +228,9 @@ export function WorkNotesPage({
</AvatarFallback>
</Avatar>
))}
{participants.length > 3 && (
{participantsList.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center">
<span className="text-slate-600 text-xs">+{participants.length - 3}</span>
<span className="text-slate-600 text-xs">+{participantsList.length - 3}</span>
</div>
)}
</div>
@ -265,7 +273,7 @@ export function WorkNotesPage({
</span>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 px-4 py-3 shadow-sm">
<p className="text-slate-700 leading-relaxed whitespace-pre-wrap">
{renderMessageWithMentions(note.message)}

View File

@ -6,7 +6,7 @@ interface Question {
id: string;
sectionName: string;
questionText: string;
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select' | 'mcq';
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select' | 'mcq' | 'radio' | 'textarea' | 'email';
options?: any;
questionOptions?: any[]; // From backend inclusion
weight: number;
@ -158,9 +158,9 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
{q.questionText} {q.isMandatory && !readOnly && <span className="text-red-500">*</span>}
</label>
{q.inputType === 'text' && (
{(q.inputType === 'text' || q.inputType === 'email') && (
<input
type="text"
type={q.inputType === 'email' ? 'email' : 'text'}
className="w-full border p-2 rounded disabled:bg-gray-100"
onChange={(e) => handleInputChange(q.id, e.target.value)}
value={responses[q.id] || ''}
@ -168,6 +168,15 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
/>
)}
{q.inputType === 'textarea' && (
<textarea
className="w-full border p-2 rounded disabled:bg-gray-100 min-h-[100px]"
onChange={(e) => handleInputChange(q.id, e.target.value)}
value={responses[q.id] || ''}
disabled={readOnly}
/>
)}
{q.inputType === 'file' && (
<div className="space-y-2">
<input
@ -202,7 +211,7 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
/>
)}
{(q.inputType === 'yesno' || q.inputType === 'select' || q.inputType === 'mcq') && (
{(q.inputType === 'yesno' || q.inputType === 'select' || q.inputType === 'mcq' || q.inputType === 'radio') && (
<div className="space-y-2">
{/* Use backend options if available, or fallbacks for legacy yesno */}
{(q.questionOptions && q.questionOptions.length > 0 ? q.questionOptions : (

View File

@ -103,6 +103,19 @@ export interface Application {
architectureCompletionDate?: string;
inaugurationDate?: string;
questionnaireResponses?: any[]; // added for response view
participants?: Participant[];
}
export interface Participant {
id: string;
userId: string;
participantType: string;
joinedMethod: string;
user?: {
name: string;
email: string;
role: string;
};
}
export interface User {

View File

@ -36,5 +36,32 @@ export const onboardingService = {
console.error('Get application by id error:', error);
throw error;
}
},
getUsers: async () => {
try {
const response = await API.getUsers();
return response.data?.data || response.data;
} catch (error) {
console.error('Get users error:', error);
throw error;
}
},
addParticipant: async (data: any) => {
try {
const response = await API.addParticipant(data);
return response.data;
} catch (error) {
console.error('Add participant error:', error);
throw error;
}
},
scheduleInterview: async (data: any) => {
try {
const response = await API.scheduleInterview(data);
return response.data;
} catch (error) {
console.error('Schedule interview error:', error);
throw error;
}
}
};