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 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), 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 // User management routes
getUsers: () => client.get('/admin/users'), getUsers: () => client.get('/admin/users'),
createUser: (data: any) => client.post('/admin/users', data), 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 { Textarea } from '../ui/textarea';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; 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 { ScrollArea } from '../ui/scroll-area';
import { import {
Table, Table,
@ -152,6 +152,7 @@ export function ApplicationDetails() {
loaDate: getStageDate('LOA'), loaDate: getStageDate('LOA'),
eorCompleteDate: getStageDate('EOR Complete'), eorCompleteDate: getStageDate('EOR Complete'),
inaugurationDate: getStageDate('Inauguration'), inaugurationDate: getStageDate('Inauguration'),
participants: data.participants || [],
}; };
setApplication(mappedApp); setApplication(mappedApp);
} catch (error) { } catch (error) {
@ -175,6 +176,7 @@ export function ApplicationDetails() {
const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false); const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false);
const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false); const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false);
const [showDocumentsModal, setShowDocumentsModal] = useState(false); const [showDocumentsModal, setShowDocumentsModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [selectedStage, setSelectedStage] = useState<string | null>(null); const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [interviewMode, setInterviewMode] = useState('virtual'); const [interviewMode, setInterviewMode] = useState('virtual');
const [approvalRemark, setApprovalRemark] = useState(''); const [approvalRemark, setApprovalRemark] = useState('');
@ -184,6 +186,25 @@ export function ApplicationDetails() {
'architectural-work': true, 'architectural-work': true,
'statutory-documents': 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) { if (loading) {
return <div>Loading application details...</div>; return <div>Loading application details...</div>;
@ -575,6 +596,50 @@ export function ApplicationDetails() {
setWorkNote(''); 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 Work Notes page is open, show that instead
if (showWorkNotesPage) { if (showWorkNotesPage) {
return ( return (
@ -584,6 +649,7 @@ export function ApplicationDetails() {
registrationNumber={application.registrationNumber} registrationNumber={application.registrationNumber}
onBack={() => setShowWorkNotesPage(false)} onBack={() => setShowWorkNotesPage(false)}
initialNotes={mockWorkNotes} initialNotes={mockWorkNotes}
participants={application.participants}
/> />
); );
} }
@ -1232,10 +1298,55 @@ export function ApplicationDetails() {
</> </>
)} )}
<Button variant="outline" className="w-full"> <Dialog open={showAssignModal} onOpenChange={setShowAssignModal}>
<User className="w-4 h-4 mr-2" /> <DialogTrigger asChild>
Assign User <Button variant="outline" className="w-full">
</Button> <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> </CardContent>
</Card> </Card>
)} )}
@ -1411,7 +1522,7 @@ export function ApplicationDetails() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Interview Type</Label> <Label>Interview Type</Label>
<Select> <Select value={interviewType} onValueChange={setInterviewType}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2">
<SelectValue placeholder="Select interview type" /> <SelectValue placeholder="Select interview type" />
</SelectTrigger> </SelectTrigger>
@ -1436,22 +1547,33 @@ export function ApplicationDetails() {
</div> </div>
<div> <div>
<Label>Date & Time</Label> <Label>Date & Time</Label>
<Input type="datetime-local" className="mt-2" /> <Input
</div> type="datetime-local"
<div> className="mt-2"
<Label>Participants</Label> value={interviewDate}
<Input placeholder="Enter participant emails (comma-separated)" className="mt-2" /> onChange={(e) => setInterviewDate(e.target.value)}
/>
</div> </div>
{interviewMode === 'virtual' && ( {interviewMode === 'virtual' && (
<div> <div>
<Label>Meeting Link</Label> <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> </div>
)} )}
{interviewMode === 'physical' && ( {interviewMode === 'physical' && (
<div> <div>
<Label>Location</Label> <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>
)} )}
<div className="flex gap-3"> <div className="flex gap-3">
@ -1464,10 +1586,7 @@ export function ApplicationDetails() {
</Button> </Button>
<Button <Button
className="flex-1 bg-amber-600 hover:bg-amber-700" className="flex-1 bg-amber-600 hover:bg-amber-700"
onClick={() => { onClick={handleScheduleInterview}
alert('Interview scheduled! Calendar invites will be sent.');
setShowScheduleModal(false);
}}
> >
Schedule Schedule
</Button> </Button>

View File

@ -12,7 +12,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
MessageSquare MessageSquare
} from 'lucide-react'; } from 'lucide-react';
import { WorkNote } from '../../lib/mock-data'; import { WorkNote, Participant } from '../../lib/mock-data';
interface WorkNotesPageProps { interface WorkNotesPageProps {
applicationId: string; applicationId: string;
@ -20,9 +20,11 @@ interface WorkNotesPageProps {
registrationNumber: string; registrationNumber: string;
onBack: () => void; onBack: () => void;
initialNotes?: WorkNote[]; initialNotes?: WorkNote[];
participants?: Participant[];
} }
interface Participant { // This interface defines the structure for participants displayed in the UI
interface ParticipantUI {
name: string; name: string;
initials: string; initials: string;
color: string; color: string;
@ -33,7 +35,8 @@ export function WorkNotesPage({
applicationName, applicationName,
registrationNumber, registrationNumber,
onBack, onBack,
initialNotes = [] initialNotes = [],
participants: externalParticipants = []
}: WorkNotesPageProps) { }: WorkNotesPageProps) {
const [notes, setNotes] = useState<WorkNote[]>(initialNotes); const [notes, setNotes] = useState<WorkNote[]>(initialNotes);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -43,13 +46,40 @@ export function WorkNotesPage({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Mock participants for @mentions const getInitials = (name: string) => {
const participants: Participant[] = [ return name
{ name: 'Sarah Chen', initials: 'SC', color: 'bg-green-600' }, .split(' ')
{ name: 'Lisa Wong', initials: 'LW', color: 'bg-blue-600' }, .map(n => n[0])
{ name: 'Mark Johnson', initials: 'MJ', color: 'bg-purple-600' }, .join('')
{ name: 'Anjali Sharma', initials: 'AS', color: 'bg-amber-600' }, .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 // Scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
@ -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 filteredParticipants = participantsList.filter(p =>
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 =>
p.name.toLowerCase().includes(mentionQuery.toLowerCase()) p.name.toLowerCase().includes(mentionQuery.toLowerCase())
); );
@ -210,7 +218,7 @@ export function WorkNotesPage({
{/* Participant Avatars */} {/* Participant Avatars */}
<div className="flex items-center -space-x-2"> <div className="flex items-center -space-x-2">
{participants.slice(0, 3).map((participant, index) => ( {participantsList.slice(0, 3).map((participant, index) => (
<Avatar <Avatar
key={index} key={index}
className="w-8 h-8 border-2 border-white" className="w-8 h-8 border-2 border-white"
@ -220,9 +228,9 @@ export function WorkNotesPage({
</AvatarFallback> </AvatarFallback>
</Avatar> </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"> <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>
)} )}
</div> </div>

View File

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

View File

@ -103,6 +103,19 @@ export interface Application {
architectureCompletionDate?: string; architectureCompletionDate?: string;
inaugurationDate?: string; inaugurationDate?: string;
questionnaireResponses?: any[]; // added for response view 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 { export interface User {

View File

@ -36,5 +36,32 @@ export const onboardingService = {
console.error('Get application by id error:', error); console.error('Get application by id error:', error);
throw 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;
}
} }
}; };