public questionnnaire as per google form and opportunity & non-opportunity . admin shortlist initiated we need to work on participant concept

This commit is contained in:
laxmanhalaki 2026-01-30 19:53:32 +05:30
parent 95a9d57dd2
commit 8ef092f723
13 changed files with 738 additions and 184 deletions

View File

@ -27,6 +27,7 @@ export const API = {
// Onboarding
submitApplication: (data: any) => client.post('/onboarding/apply', data),
getApplications: () => client.get('/onboarding/applications'),
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),
createQuestionnaireVersion: (data: any) => client.post('/questionnaire/version', data),

View File

@ -8,8 +8,8 @@ interface Question {
id?: string;
sectionName: string;
questionText: string;
inputType: 'text' | 'yesno' | 'file' | 'number';
options?: any;
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select';
options?: { text: string; score: number }[];
weight: number;
order: number;
isMandatory: boolean;
@ -53,10 +53,21 @@ const QuestionnaireBuilder: React.FC = () => {
const data = response.data.data;
setVersion(`${data.version} (Copy)`); // Default to making a copy
if (data.questions && data.questions.length > 0) {
setQuestions(data.questions.map((q: any) => ({
setQuestions(data.questions.map((q: any) => {
let normalizedType = q.inputType?.toLowerCase().trim();
if (normalizedType === 'mcq') normalizedType = 'select';
// Fallback validity check
const validTypes = ['text', 'number', 'file', 'yesno', 'select'];
if (!validTypes.includes(normalizedType)) normalizedType = 'text';
return {
...q,
weight: parseFloat(q.weight) // Ensure weight is number
})));
inputType: normalizedType,
weight: parseFloat(q.weight), // Ensure weight is number
options: q.questionOptions?.map((opt: any) => ({ text: opt.optionText, score: opt.score })) || []
};
}));
}
} else {
toast.error('Failed to load questionnaire');
@ -92,10 +103,47 @@ const QuestionnaireBuilder: React.FC = () => {
const updateQuestion = (index: number, field: keyof Question, value: any) => {
const newQuestions = [...questions];
// Auto-populate Yes/No options if switching to yesno
if (field === 'inputType' && value === 'yesno' && (!newQuestions[index].options || newQuestions[index].options?.length === 0)) {
newQuestions[index].options = [
{ text: 'Yes', score: 5 },
{ text: 'No', score: 0 }
];
} else if (field === 'inputType' && value === 'select' && (!newQuestions[index].options)) {
newQuestions[index].options = [];
}
newQuestions[index] = { ...newQuestions[index], [field]: value };
setQuestions(newQuestions);
};
const addOption = (questionIndex: number) => {
const newQuestions = [...questions];
if (!newQuestions[questionIndex].options) newQuestions[questionIndex].options = [];
newQuestions[questionIndex].options!.push({ text: '', score: 0 });
setQuestions(newQuestions);
};
const updateOption = (questionIndex: number, optionIndex: number, field: 'text' | 'score', value: any) => {
const newQuestions = [...questions];
if (newQuestions[questionIndex].options) {
newQuestions[questionIndex].options![optionIndex] = {
...newQuestions[questionIndex].options![optionIndex],
[field]: value
};
setQuestions(newQuestions);
}
};
const removeOption = (questionIndex: number, optionIndex: number) => {
const newQuestions = [...questions];
if (newQuestions[questionIndex].options) {
newQuestions[questionIndex].options = newQuestions[questionIndex].options!.filter((_, i) => i !== optionIndex);
setQuestions(newQuestions);
}
};
const handleSave = async () => {
if (questions.some(q => !q.questionText)) {
toast.error('All questions must have text');
@ -185,7 +233,8 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-12 gap-5">
<div className="flex-1 flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
<div className="md:col-span-6 lg:col-span-5">
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Question Text</label>
<input
@ -216,9 +265,10 @@ const QuestionnaireBuilder: React.FC = () => {
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none bg-white"
>
<option value="text">Text Input</option>
<option value="yesno">Yes / No</option>
<option value="number">Numeric</option>
<option value="file">File Upload</option>
<option value="yesno">Yes / No</option>
<option value="select">Dropdown / Multi-Choice</option>
</select>
</div>
@ -249,6 +299,51 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
</div>
{/* Options Editor for Select/YesNo */}
{(q.inputType === 'select' || q.inputType === 'yesno') && (
<div className="w-full mt-4 pl-4 md:pl-16 border-t border-slate-100 pt-4">
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Answer Options & Scores
</label>
<div className="space-y-2">
{q.options?.map((opt, optIndex) => (
<div key={optIndex} className="flex items-center gap-3">
<input
type="text"
value={opt.text}
onChange={(e) => updateOption(index, optIndex, 'text', e.target.value)}
className="flex-1 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
placeholder={`Option ${optIndex + 1}`}
/>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 font-medium">Score:</span>
<input
type="number"
value={opt.score}
onChange={(e) => updateOption(index, optIndex, 'score', parseFloat(e.target.value))}
className="w-20 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
/>
</div>
<button
onClick={() => removeOption(index, optIndex)}
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
title="Remove Option"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
<button
onClick={() => addOption(index)}
className="mt-3 text-sm flex items-center gap-1 text-amber-600 hover:text-amber-700 font-medium"
>
<Plus size={16} /> Add Option
</button>
</div>
)}
</div>
<button
onClick={() => removeQuestion(index)}
className="absolute -right-3 -top-3 md:static md:mt-8 md:mr-2 w-8 h-8 flex items-center justify-center rounded-full bg-white md:bg-transparent text-slate-400 hover:text-red-600 hover:bg-red-50 border border-slate-200 md:border-transparent shadow-sm md:shadow-none transition-all"
@ -266,7 +361,7 @@ const QuestionnaireBuilder: React.FC = () => {
>
<Plus size={20} /> Add Another Question
</button>
</div>
</div >
);
};

View File

@ -4,6 +4,7 @@ import { mockApplications, mockAuditLogs, mockDocuments, mockWorkNotes, mockLeve
import { onboardingService } from '../../services/onboarding.service';
import { WorkNotesPage } from './WorkNotesPage';
import QuestionnaireForm from '../dealer/QuestionnaireForm';
import QuestionnaireResponseView from './QuestionnaireResponseView';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
@ -118,7 +119,8 @@ export function ApplicationDetails() {
ownsBike: data.ownRoyalEnfield === 'yes',
pastExperience: data.experienceYears ? `${data.experienceYears} years` : (data.description || ''),
status: data.overallStatus as ApplicationStatus,
questionnaireMarks: 0,
questionnaireMarks: data.score || data.questionnaireMarks || 0, // Read from score or correct field
questionnaireResponses: data.questionnaireResponses || [], // Map responses
rank: 0,
totalApplicantsAtLocation: 0,
submissionDate: data.createdAt,
@ -769,17 +771,7 @@ export function ApplicationDetails() {
<CardContent>
{/* Questionnaire Response Tab */}
<TabsContent value="questionnaire" className="space-y-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ClipboardList className="w-5 h-5 text-amber-600" />
<h3 className="text-slate-900">Questionnaire Responses</h3>
</div>
{application.questionnaireMarks !== undefined && (
<Badge className="bg-amber-600">Score: {application.questionnaireMarks}/100</Badge>
)}
</div>
<QuestionnaireForm applicationId={application.id} readOnly={true} />
<QuestionnaireResponseView application={application} />
</TabsContent>
{/* Progress Tab */}

View File

@ -30,6 +30,8 @@ import {
import { Progress } from '../ui/progress';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { Label } from '../ui/label';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
interface ApplicationsPageProps {
onViewDetails: (id: string) => void;
@ -37,6 +39,7 @@ interface ApplicationsPageProps {
}
export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsPageProps) {
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const [searchQuery, setSearchQuery] = useState('');
const [locationFilter, setLocationFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
@ -44,6 +47,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'date'>('date');
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
const [showMyAssignments, setShowMyAssignments] = useState(false);
// Real Data Integration
const [applications, setApplications] = useState<Application[]>([]);
@ -77,7 +81,8 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
rank: 0,
totalApplicantsAtLocation: 0,
submissionDate: app.createdAt,
assignedUsers: [],
assignedUsers: [], // Keeping this for UI compatibility if needed
assignedTo: app.assignedTo, // Add this field for filtering
progress: app.progressPercentage || 0,
isShortlisted: true, // Show all for admin view
// Add other fields to match interface
@ -117,7 +122,10 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const isShortlisted = app.isShortlisted === true; // Only show shortlisted applications
const notExcluded = !excludedApplicationIds.includes(app.id); // Exclude APP-005, 006, 007, 008
return matchesSearch && matchesLocation && matchesStatus && isShortlisted && notExcluded;
// New Filter: My Assignments
const matchesAssignment = !showMyAssignments || ((app as any).assignedTo === currentUser?.id);
return matchesSearch && matchesLocation && matchesStatus && isShortlisted && notExcluded && matchesAssignment;
})
.sort((a, b) => {
if (sortBy === 'date') {
@ -240,6 +248,16 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</SelectContent>
</Select>
{/* My Assignments Filter */}
<div className="flex items-center space-x-2">
<Checkbox
id="my-assignments"
checked={showMyAssignments}
onCheckedChange={(checked) => setShowMyAssignments(checked as boolean)}
/>
<Label htmlFor="my-assignments">My Assignments Only</Label>
</div>
{/* Sort By */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
<SelectTrigger className="w-full lg:w-40">

View File

@ -1,5 +1,7 @@
import { useState } from 'react';
import { mockApplications, locations, states, ApplicationStatus } from '../../lib/mock-data';
import { useState, useEffect } from 'react';
import { locations, states, ApplicationStatus, Application } from '../../lib/mock-data';
import { onboardingService } from '../../services/onboarding.service';
import { adminService } from '../../services/admin.service';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
@ -16,16 +18,19 @@ import {
Mail,
Grid3x3,
List,
AlertCircle
AlertCircle,
Loader2,
X,
User as UserIcon
} from 'lucide-react';
import { Badge } from '../ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell
} from '../ui/table';
import { Progress } from '../ui/progress';
import { Checkbox } from '../ui/checkbox';
@ -34,11 +39,27 @@ import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { toast } from 'sonner';
import { ApplicationCard } from './ApplicationCard';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
interface OpportunityRequestsPageProps {
onViewDetails: (id: string) => void;
}
interface User {
id: string;
fullName: string;
email: string;
role: string;
}
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
const [searchQuery, setSearchQuery] = useState('');
@ -48,23 +69,101 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState('');
const [assigneeEmail, setAssigneeEmail] = useState('');
const [applicationsData, setApplicationsData] = useState(mockApplications);
// Assignee Selection
const [selectedAssignees, setSelectedAssignees] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [openUserSelect, setOpenUserSelect] = useState(false);
// Real data integration
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchApplications();
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await adminService.getAllUsers();
// Defensive check for array data
const users = (response && response.success && Array.isArray(response.data))
? response.data
: (Array.isArray(response) ? response : []);
// Filter out any invalid user objects
setAvailableUsers(users.filter((u: any) => u && u.id));
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
const fetchApplications = async () => {
try {
setLoading(true);
const response = await onboardingService.getApplications();
const rawData = response.data || (Array.isArray(response) ? response : []);
// Map backend data to Application interface
const mappedApps: Application[] = rawData.map((app: any) => ({
id: app.id,
registrationNumber: app.applicationId || 'N/A',
name: app.applicantName,
email: app.email,
phone: app.phone,
age: app.age,
education: app.education,
residentialAddress: app.address || app.city || '',
businessAddress: app.address || '',
preferredLocation: app.preferredLocation,
state: app.state,
ownsBike: app.ownRoyalEnfield === 'yes',
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
status: app.overallStatus as ApplicationStatus,
questionnaireMarks: app.score || app.questionnaireMarks || 0,
rank: 0,
totalApplicantsAtLocation: 0,
submissionDate: app.createdAt,
assignedUsers: [],
progress: app.progressPercentage || 0,
isShortlisted: app.isShortlisted,
ddLeadShortlisted: app.ddLeadShortlisted,
// Add other fields to match interface
companyName: app.companyName,
source: app.source,
existingDealer: app.existingDealer,
royalEnfieldModel: app.royalEnfieldModel,
description: app.description,
pincode: app.pincode,
locationType: app.locationType,
ownRoyalEnfield: app.ownRoyalEnfield,
address: app.address
}));
setApplicationsData(mappedApps);
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load opportunity requests');
} finally {
setLoading(false);
}
};
// Filter applications that match preferred locations (opportunity requests)
// These are applications where we are currently offering dealerships
// Shows applications shortlisted by DD but NOT yet shortlisted by DD Lead
// IMPORTANT: Only shows applications in early stages (before they enter full workflow)
// UPDATED LOGIC: Opportunities start at 'Questionnaire Pending'. 'Submitted' means Non-Opportunity.
const filteredApplications = applicationsData.filter((app) => {
// Only show applications that are:
// 1. Shortlisted by DD (isShortlisted = true) - meaning it's an opportunity
// 2. NOT yet shortlisted by DD Lead (ddLeadShortlisted !== true) - waiting for DD Lead action
// 3. In early stages ONLY (Submitted, Questionnaire Pending, Questionnaire Completed)
const isOpportunity = app.isShortlisted === true && !(app as any).ddLeadShortlisted;
// 1. Not Shortlisted by DD Lead yet (ddLeadShortlisted !== true) - waiting for action
const waitingForDDLead = !(app as any).ddLeadShortlisted;
// Only show applications with early-stage statuses
const validStatuses: ApplicationStatus[] = ['Submitted', 'Questionnaire Pending', 'Questionnaire Completed'];
const isEarlyStage = validStatuses.includes(app.status);
// Only show applications with Opportunity statuses
// 'Submitted' is EXCLUDED because it represents Non-Opportunity (Leads)
const validStatuses: ApplicationStatus[] = ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'];
const isOpportunityStatus = validStatuses.includes(app.status);
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase());
@ -72,7 +171,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
const matchesState = stateFilter === 'all' || app.state === stateFilter;
return isOpportunity && isEarlyStage && matchesSearch && matchesStatus && matchesLocation && matchesState;
return waitingForDDLead && isOpportunityStatus && matchesSearch && matchesStatus && matchesLocation && matchesState;
});
const handleSelectAll = (checked: boolean) => {
@ -99,26 +198,26 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setShowShortlistModal(true);
};
const confirmShortlist = () => {
if (!assigneeEmail.trim()) {
toast.error('Please enter an email to assign the applications');
const confirmShortlist = async () => {
if (selectedAssignees.length === 0) {
toast.error('Please assign at least one user');
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(assigneeEmail)) {
toast.error('Please enter a valid email address');
return;
}
const assignedUserIds = selectedAssignees.map(u => u.id);
// Update applications to mark them as shortlisted by DD Lead
try {
// Call Backend API
const response = await onboardingService.shortlistApplications(selectedIds, assignedUserIds, shortlistRemark);
if (response && response.success) {
// Update local state and show success only if API succeeded
const updatedApplications = applicationsData.map(app => {
if (selectedIds.includes(app.id)) {
return {
...app,
ddLeadShortlisted: true,
assignedTo: assigneeEmail
assignedTo: assignedUserIds[0] // Optimistically update with first assignee
} as any;
}
return app;
@ -128,9 +227,16 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setSelectedIds([]);
setShowShortlistModal(false);
setShortlistRemark('');
setAssigneeEmail('');
setSelectedAssignees([]);
toast.success(`${selectedIds.length} application(s) shortlisted and assigned to ${assigneeEmail}`);
toast.success(`${selectedIds.length} application(s) shortlisted and assigned to ${selectedAssignees.length} user(s)`);
} else {
throw new Error(response?.message || 'Failed to process shortlisting');
}
} catch (error: any) {
console.error('Failed to shortlist applications:', error);
toast.error(error.message || 'Failed to process shortlisting');
}
};
const handleBulkReminders = () => {
@ -174,12 +280,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
'Statutory Check': 'bg-emerald-100 text-emerald-800',
'Statutory Partnership': 'bg-emerald-100 text-emerald-800',
'Statutory Firm Reg': 'bg-emerald-100 text-emerald-800',
'Statutory Rental': 'bg-emerald-100 text-emerald-800',
'Statutory Virtual Code': 'bg-emerald-100 text-emerald-800',
'Statutory Domain': 'bg-emerald-100 text-emerald-800',
'Statutory MSD': 'bg-emerald-100 text-emerald-800',
'Statutory LOI Ack': 'bg-emerald-100 text-emerald-800',
'EOR In Progress': 'bg-violet-100 text-violet-800',
'LOA Pending': 'bg-pink-100 text-pink-800',
'Inauguration': 'bg-green-100 text-green-800',
'Approved': 'bg-green-100 text-green-800',
'Rejected': 'bg-red-100 text-red-800',
'Disqualified': 'bg-gray-100 text-gray-800'
@ -187,6 +295,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
return colors[status] || 'bg-gray-100 text-gray-800';
};
if (loading) {
return (
<div className="flex justify-center items-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Info Banner */}
@ -421,27 +537,83 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</div>
)}
{/* Shortlist Modal with Email Assignment */}
{/* Shortlist Modal with Multi-User Assignment */}
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
<DialogContent>
<DialogContent className="overflow-visible">
<DialogHeader>
<DialogTitle>Shortlist & Assign Applications</DialogTitle>
<DialogDescription>
You are about to shortlist {selectedIds.length} application(s). These applications will be moved to the Dealership Requests page.
You are about to shortlist {selectedIds.length} application(s). Select users to assign them to.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Assign to User Email *</Label>
<Input
type="email"
placeholder="Enter email address to assign applications..."
value={assigneeEmail}
onChange={(e) => setAssigneeEmail(e.target.value)}
className="mt-2"
/>
<p className="text-slate-500 text-sm mt-1">The selected applications will be assigned to this user for processing</p>
<div className="flex flex-col gap-2">
<Label>Assign to Users *</Label>
{/* Selected Users Badges */}
<div className="flex flex-wrap gap-2 mb-2 p-2 border rounded-md min-h-[42px]">
{(!selectedAssignees || selectedAssignees.length === 0) && <span className="text-slate-400 text-sm py-1">No users selected</span>}
{selectedAssignees?.map(user => (
user ? (
<Badge key={user.id} variant="secondary" className="pl-2 pr-1 py-1 flex items-center gap-1">
{user.fullName || user.email || 'Unknown User'}
<button
onClick={() => setSelectedAssignees(prev => prev.filter(u => u.id !== user.id))}
className="ml-1 hover:bg-slate-200 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
) : null
))}
</div>
{/* User Search Combobox */}
<Popover open={openUserSelect} onOpenChange={setOpenUserSelect}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={openUserSelect} className="justify-between">
Select users to add...
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[400px]" align="start">
<Command>
<CommandInput placeholder="Search users by name or email..." />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup heading="Available Users">
{availableUsers
?.filter(user => user && !selectedAssignees.some(selected => selected.id === user.id))
.map((user) => (
<CommandItem
key={user.id}
onSelect={() => {
setSelectedAssignees([...selectedAssignees, user]);
setOpenUserSelect(false);
}}
className="cursor-pointer"
>
<UserIcon className="mr-2 h-4 w-4 text-slate-500" />
<div className="flex flex-col">
<span>{user.fullName || 'Unknown Name'}</span>
<span className="text-xs text-slate-500">
{user.email || 'No Email'} {
(typeof user.role === 'object' && user.role !== null)
? (user.role as any).roleName
: (user.role || 'No Role')
}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-slate-500 text-sm">Use the search to find and add multiple interviewers/assignees.</p>
</div>
<div>
<Label>Shortlisting Remark (Optional)</Label>
<Textarea
@ -452,13 +624,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
rows={4}
/>
</div>
<div className="flex gap-3">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setShowShortlistModal(false);
setAssigneeEmail('');
setSelectedAssignees([]);
setShortlistRemark('');
}}
>

View File

@ -0,0 +1,116 @@
import React from 'react';
import { Badge } from '../ui/badge';
import { ClipboardList } from 'lucide-react';
interface QuestionnaireResponseViewProps {
application: any;
}
const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ application }) => {
// If no responses or empty array
if (!application.questionnaireResponses || application.questionnaireResponses.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 bg-slate-50 rounded-lg border border-dashed border-slate-300">
<ClipboardList className="w-12 h-12 mb-3 text-slate-300" />
<h3 className="text-lg font-medium text-slate-700">Response is Pending</h3>
<p className="text-sm">The applicant has not submitted the questionnaire yet.</p>
</div>
);
}
// Sort responses by question order if possible, or just index
// Assuming backend returns them in some order, better to sort by question.order if available
const responses = [...application.questionnaireResponses].sort((a, b) => {
return (a.question?.order || 0) - (b.question?.order || 0);
});
const totalScore = application.score || application.questionnaireMarks || 0; // Fallback mapping
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ClipboardList className="w-5 h-5 text-amber-600" />
<h3 className="text-slate-900">Questionnaire Responses</h3>
</div>
{totalScore !== undefined && (
<Badge className="bg-amber-600">Score: {totalScore}/100</Badge>
)}
</div>
<div className="space-y-6">
{responses.map((resp: any, index: number) => {
const question = resp.question;
const questionText = question?.questionText || 'Unknown Question';
const answer = resp.responseValue || 'No Answer';
const section = question?.sectionName || 'General';
const options = question?.questionOptions || [];
// Match answer to find score
// Note: This relies on exact string match.
const matchedOption = options.find((opt: any) => opt.optionText === answer);
const score = matchedOption ? matchedOption.score : 0;
const maxScore = Math.max(...options.map((o: any) => o.score || 0), 0);
const isFile = typeof answer === 'string' && answer.startsWith('data:');
const isImage = isFile && answer.startsWith('data:image');
return (
<div key={resp.id} className="border border-slate-200 rounded-lg p-5 hover:border-amber-300 transition-colors">
<div className="flex items-start gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="text-amber-600">{index + 1}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-slate-600 bg-slate-50">
{section}
</Badge>
{(options.length > 0) && (
<Badge className={score > 0 ? "bg-green-600" : "bg-slate-400"}>
{score}/{maxScore > 0 ? maxScore : '?'}
</Badge>
)}
</div>
<h4 className="text-slate-900 font-medium">{questionText}</h4>
</div>
</div>
<div className="ml-11">
{isImage ? (
<div className="mt-2">
<img
src={answer}
alt="Response Attachment"
className="max-w-full h-auto max-h-64 rounded border p-1 object-contain"
/>
</div>
) : isFile ? (
<a
href={answer}
download={`upload_${index}.pdf`}
className="text-blue-600 underline text-sm break-all"
>
Download Attachment
</a>
) : (
<p className="text-slate-600 leading-relaxed break-words whitespace-pre-wrap">
{resp.attachmentUrl ? (
<a href={resp.attachmentUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">
View Attachment
</a>
) : (
answer
)}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
export default QuestionnaireResponseView;

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { mockApplications, locations, states } from '../../lib/mock-data';
import { useState, useEffect } from 'react';
import { mockApplications, locations, states, Application, ApplicationStatus } from '../../lib/mock-data';
import { onboardingService } from '../../services/onboarding.service';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
@ -12,7 +13,8 @@ import {
import {
Search,
Download,
Database
Database,
Loader2
} from 'lucide-react';
import { Badge } from '../ui/badge';
import {
@ -23,6 +25,7 @@ import {
TableHeader,
TableRow,
} from '../ui/table';
import { toast } from 'sonner';
interface UnopportunityRequestsPageProps {
onViewDetails: (id: string) => void;
@ -33,13 +36,71 @@ export function UnopportunityRequestsPage({ onViewDetails }: UnopportunityReques
const [locationFilter, setLocationFilter] = useState<string>('all');
const [stateFilter, setStateFilter] = useState<string>('all');
// Real data integration
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchApplications();
}, []);
const fetchApplications = async () => {
try {
setLoading(true);
const response = await onboardingService.getApplications();
const rawData = response.data || (Array.isArray(response) ? response : []);
// Map backend data to Application interface
const mappedApps: Application[] = rawData.map((app: any) => ({
id: app.id,
registrationNumber: app.applicationId || 'N/A',
name: app.applicantName,
email: app.email,
phone: app.phone,
age: app.age,
education: app.education,
residentialAddress: app.address || app.city || '',
businessAddress: app.address || '',
preferredLocation: app.preferredLocation,
state: app.state,
ownsBike: app.ownRoyalEnfield === 'yes',
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
status: app.overallStatus as ApplicationStatus,
questionnaireMarks: app.score || app.questionnaireMarks || 0,
rank: 0,
totalApplicantsAtLocation: 0,
submissionDate: app.createdAt,
assignedUsers: [],
progress: app.progressPercentage || 0,
isShortlisted: app.isShortlisted, // Backend provides this
// Add other fields to match interface
companyName: app.companyName,
source: app.source,
existingDealer: app.existingDealer,
royalEnfieldModel: app.royalEnfieldModel,
description: app.description,
pincode: app.pincode,
locationType: app.locationType,
ownRoyalEnfield: app.ownRoyalEnfield,
address: app.address
}));
setApplicationsData(mappedApps);
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load unopportunity requests');
} finally {
setLoading(false);
}
};
// Filter unopportunity leads - These are lead generation submissions
// People who expressed interest but received unopportunity email because
// we're currently not offering dealerships in their preferred location
const filteredLeads = mockApplications.filter((app) => {
// Only show applications that have not been shortlisted by DD
// These are pure leads for future reference
const isUnopportunity = !app.isShortlisted;
// UPDATED LOGIC: 'Submitted' status specifically implies Non-Opportunity (Lead)
const filteredLeads = applicationsData.filter((app) => {
// Only show applications with 'Submitted' status
const isUnopportunity = app.status === 'Submitted';
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||

View File

@ -5,6 +5,7 @@ import { Label } from '../ui/label';
import { Checkbox } from '../ui/checkbox';
import { AlertCircle, Copy, Check } from 'lucide-react';
import { mockUsers } from '../../lib/mock-data';
import { toast } from 'sonner';
interface LoginPageProps {
onLogin: (email: string, password: string) => void;
@ -62,8 +63,10 @@ export function LoginPage({ onLogin }: LoginPageProps) {
setIsLoading(true);
try {
await onLogin(userEmail, userPassword);
} catch (err) {
setError('Auto-login failed');
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Auto-login failed';
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
@ -83,8 +86,10 @@ export function LoginPage({ onLogin }: LoginPageProps) {
setIsLoading(true);
try {
await onLogin(email, password);
} catch (err) {
setError('An unexpected error occurred');
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Login failed';
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}

View File

@ -6,8 +6,9 @@ interface Question {
id: string;
sectionName: string;
questionText: string;
inputType: 'text' | 'yesno' | 'file' | 'number';
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select' | 'mcq';
options?: any;
questionOptions?: any[]; // From backend inclusion
weight: number;
order: number;
isMandatory: boolean;
@ -59,8 +60,14 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
}
const res = await API.getLatestQuestionnaire();
if (res.data && res.data.data && res.data.data.questions) {
setQuestions(res.data.data.questions);
// Normalize questions
const normalized = res.data.data.questions.map((q: any) => ({
...q,
inputType: (q.inputType === 'mcq') ? 'select' : q.inputType
}));
setQuestions(normalized);
}
} catch (error) {
console.error(error);
@ -75,8 +82,25 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
setResponses(prev => ({ ...prev, [questionId]: value }));
};
const handleFileChange = (questionId: string, e: React.ChangeEvent<HTMLInputElement>) => {
if (readOnly) return;
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.size > 5 * 1024 * 1024) {
toast.error("File size must be less than 5MB");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
handleInputChange(questionId, reader.result);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async () => {
const missing = questions.filter(q => q.isMandatory && !responses[q.id]);
// Skip mandatory check for 'file' type as per user request to bypass
const missing = questions.filter(q => q.isMandatory && q.inputType !== 'file' && !responses[q.id]);
if (missing.length > 0) {
toast.error(`Please answer all mandatory questions. Missing: ${missing.length}`);
return;
@ -144,6 +168,30 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
/>
)}
{q.inputType === 'file' && (
<div className="space-y-2">
<input
type="file"
className="w-full text-sm text-slate-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-amber-50 file:text-amber-700
hover:file:bg-amber-100"
onChange={(e) => handleFileChange(q.id, e)}
disabled={readOnly}
/>
{responses[q.id] && (
<div className="text-xs text-green-600 font-medium flex items-center gap-1">
File Attached
</div>
)}
<div className="text-xs text-slate-400">
(Optional for now)
</div>
</div>
)}
{q.inputType === 'number' && (
<input
type="number"
@ -154,28 +202,32 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
/>
)}
{q.inputType === 'yesno' && (
<div className="flex gap-4">
<label className="flex items-center gap-2">
{(q.inputType === 'yesno' || q.inputType === 'select' || q.inputType === 'mcq') && (
<div className="space-y-2">
{/* Use backend options if available, or fallbacks for legacy yesno */}
{(q.questionOptions && q.questionOptions.length > 0 ? q.questionOptions : (
q.inputType === 'yesno' ? [{ optionText: 'Yes' }, { optionText: 'No' }] : []
)).map((opt: any, idx: number) => {
const val = opt.optionText || opt.text;
return (
<label key={idx} className="flex items-center gap-2 cursor-pointer p-1 hover:bg-slate-50 rounded">
<input
type="radio"
name={`q-${q.id}`}
value="yes"
checked={responses[q.id] === 'yes'}
onChange={() => handleInputChange(q.id, 'yes')}
value={val}
checked={responses[q.id] === val}
onChange={() => handleInputChange(q.id, val)}
disabled={readOnly}
/> Yes
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name={`q-${q.id}`}
value="no"
checked={responses[q.id] === 'no'}
onChange={() => handleInputChange(q.id, 'no')}
disabled={readOnly}
/> No
className="text-amber-600 focus:ring-amber-500 w-4 h-4"
/>
<span className="text-gray-700">{val}</span>
</label>
);
})}
{/* Fallback if select but no options (shouldn't happen ideally) */}
{(!q.questionOptions || q.questionOptions.length === 0) && q.inputType !== 'yesno' && (
<div className="text-sm text-red-400 italic">No options defined.</div>
)}
</div>
)}
</div>

View File

@ -72,7 +72,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
}
// Add All Requests for DD Lead role (before Dealership Requests)
if (currentUser?.role === 'DD Lead') {
if (currentUser?.role === 'DD Lead' || currentUser?.role === 'Super Admin') {
menuItems.splice(1, 0, {
id: 'all-requests',
label: 'All Requests',

View File

@ -102,6 +102,7 @@ export interface Application {
architectureDocumentDate?: string;
architectureCompletionDate?: string;
inaugurationDate?: string;
questionnaireResponses?: any[]; // added for response view
}
export interface User {
@ -202,6 +203,13 @@ export const mockUsers: User[] = [
password: 'Admin@123',
role: 'DD Lead',
},
{
id: '18',
name: 'Lince',
email: 'lince@gmail.com',
password: 'Admin@123',
role: 'DD Admin',
},
];
// Mock current user (default)

View File

@ -23,14 +23,38 @@ const PublicQuestionnairePage: React.FC = () => {
} else {
setIsValid(false);
}
} catch (error) {
} catch (error: any) {
if (error.response?.data?.code === 'ALREADY_SUBMITTED') {
setIsValid('submitted' as any); // Use a string or enum for distinct state
} else {
setIsValid(false);
}
}
};
checkValidity();
}, [applicationId]);
if (isValid === null) return <div className="p-8 text-center">Checking application link...</div>;
if (isValid === 'submitted' as any) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-6">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold mb-2">Assessment Submitted</h2>
<p className="text-gray-600 mb-6">
Thank you! Your assessment has already been submitted successfully.
We will review your application and get back to you shortly.
</p>
</div>
</div>
);
}
if (isValid === false) return <div className="p-8 text-center text-red-500 text-xl font-bold">Invalid or Expired Link</div>;
return (

View File

@ -19,6 +19,15 @@ export const onboardingService = {
throw error;
}
},
shortlistApplications: async (applicationIds: string[], assignedTo: string[], remarks?: string) => {
try {
const response = await API.shortlistApplications({ applicationIds, assignedTo, remarks });
return response.data;
} catch (error) {
console.error('Shortlist applications error:', error);
throw error;
}
},
getApplicationById: async (id: string) => {
try {
const response = await API.getApplicationById(id);