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:
parent
95a9d57dd2
commit
8ef092f723
@ -27,6 +27,7 @@ export const API = {
|
|||||||
// Onboarding
|
// Onboarding
|
||||||
submitApplication: (data: any) => client.post('/onboarding/apply', data),
|
submitApplication: (data: any) => client.post('/onboarding/apply', data),
|
||||||
getApplications: () => client.get('/onboarding/applications'),
|
getApplications: () => client.get('/onboarding/applications'),
|
||||||
|
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
|
||||||
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
|
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
|
||||||
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),
|
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),
|
||||||
createQuestionnaireVersion: (data: any) => client.post('/questionnaire/version', data),
|
createQuestionnaireVersion: (data: any) => client.post('/questionnaire/version', data),
|
||||||
|
|||||||
@ -8,8 +8,8 @@ interface Question {
|
|||||||
id?: string;
|
id?: string;
|
||||||
sectionName: string;
|
sectionName: string;
|
||||||
questionText: string;
|
questionText: string;
|
||||||
inputType: 'text' | 'yesno' | 'file' | 'number';
|
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select';
|
||||||
options?: any;
|
options?: { text: string; score: number }[];
|
||||||
weight: number;
|
weight: number;
|
||||||
order: number;
|
order: number;
|
||||||
isMandatory: boolean;
|
isMandatory: boolean;
|
||||||
@ -53,10 +53,21 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
const data = response.data.data;
|
const data = response.data.data;
|
||||||
setVersion(`${data.version} (Copy)`); // Default to making a copy
|
setVersion(`${data.version} (Copy)`); // Default to making a copy
|
||||||
if (data.questions && data.questions.length > 0) {
|
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,
|
...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 {
|
} else {
|
||||||
toast.error('Failed to load questionnaire');
|
toast.error('Failed to load questionnaire');
|
||||||
@ -92,10 +103,47 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
|
|
||||||
const updateQuestion = (index: number, field: keyof Question, value: any) => {
|
const updateQuestion = (index: number, field: keyof Question, value: any) => {
|
||||||
const newQuestions = [...questions];
|
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 };
|
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
||||||
setQuestions(newQuestions);
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (questions.some(q => !q.questionText)) {
|
if (questions.some(q => !q.questionText)) {
|
||||||
toast.error('All questions must have text');
|
toast.error('All questions must have text');
|
||||||
@ -185,7 +233,8 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Question Text</label>
|
||||||
<input
|
<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"
|
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="text">Text Input</option>
|
||||||
<option value="yesno">Yes / No</option>
|
|
||||||
<option value="number">Numeric</option>
|
<option value="number">Numeric</option>
|
||||||
<option value="file">File Upload</option>
|
<option value="file">File Upload</option>
|
||||||
|
<option value="yesno">Yes / No</option>
|
||||||
|
<option value="select">Dropdown / Multi-Choice</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -249,6 +299,51 @@ const QuestionnaireBuilder: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={() => removeQuestion(index)}
|
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"
|
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"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { mockApplications, mockAuditLogs, mockDocuments, mockWorkNotes, mockLeve
|
|||||||
import { onboardingService } from '../../services/onboarding.service';
|
import { onboardingService } from '../../services/onboarding.service';
|
||||||
import { WorkNotesPage } from './WorkNotesPage';
|
import { WorkNotesPage } from './WorkNotesPage';
|
||||||
import QuestionnaireForm from '../dealer/QuestionnaireForm';
|
import QuestionnaireForm from '../dealer/QuestionnaireForm';
|
||||||
|
import QuestionnaireResponseView from './QuestionnaireResponseView';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
@ -118,7 +119,8 @@ export function ApplicationDetails() {
|
|||||||
ownsBike: data.ownRoyalEnfield === 'yes',
|
ownsBike: data.ownRoyalEnfield === 'yes',
|
||||||
pastExperience: data.experienceYears ? `${data.experienceYears} years` : (data.description || ''),
|
pastExperience: data.experienceYears ? `${data.experienceYears} years` : (data.description || ''),
|
||||||
status: data.overallStatus as ApplicationStatus,
|
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,
|
rank: 0,
|
||||||
totalApplicantsAtLocation: 0,
|
totalApplicantsAtLocation: 0,
|
||||||
submissionDate: data.createdAt,
|
submissionDate: data.createdAt,
|
||||||
@ -769,17 +771,7 @@ export function ApplicationDetails() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Questionnaire Response Tab */}
|
{/* Questionnaire Response Tab */}
|
||||||
<TabsContent value="questionnaire" className="space-y-6">
|
<TabsContent value="questionnaire" className="space-y-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<QuestionnaireResponseView application={application} />
|
||||||
<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} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Progress Tab */}
|
{/* Progress Tab */}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ import {
|
|||||||
import { Progress } from '../ui/progress';
|
import { Progress } from '../ui/progress';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../../store';
|
||||||
|
|
||||||
interface ApplicationsPageProps {
|
interface ApplicationsPageProps {
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
@ -37,6 +39,7 @@ interface ApplicationsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsPageProps) {
|
export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsPageProps) {
|
||||||
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
|
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
|
||||||
@ -44,6 +47,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<'date'>('date');
|
const [sortBy, setSortBy] = useState<'date'>('date');
|
||||||
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
|
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
|
||||||
|
const [showMyAssignments, setShowMyAssignments] = useState(false);
|
||||||
|
|
||||||
// Real Data Integration
|
// Real Data Integration
|
||||||
const [applications, setApplications] = useState<Application[]>([]);
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
@ -77,7 +81,8 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
rank: 0,
|
rank: 0,
|
||||||
totalApplicantsAtLocation: 0,
|
totalApplicantsAtLocation: 0,
|
||||||
submissionDate: app.createdAt,
|
submissionDate: app.createdAt,
|
||||||
assignedUsers: [],
|
assignedUsers: [], // Keeping this for UI compatibility if needed
|
||||||
|
assignedTo: app.assignedTo, // Add this field for filtering
|
||||||
progress: app.progressPercentage || 0,
|
progress: app.progressPercentage || 0,
|
||||||
isShortlisted: true, // Show all for admin view
|
isShortlisted: true, // Show all for admin view
|
||||||
// Add other fields to match interface
|
// 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 isShortlisted = app.isShortlisted === true; // Only show shortlisted applications
|
||||||
const notExcluded = !excludedApplicationIds.includes(app.id); // Exclude APP-005, 006, 007, 008
|
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) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === 'date') {
|
if (sortBy === 'date') {
|
||||||
@ -240,6 +248,16 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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 */}
|
{/* Sort By */}
|
||||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
|
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
|
||||||
<SelectTrigger className="w-full lg:w-40">
|
<SelectTrigger className="w-full lg:w-40">
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mockApplications, locations, states, ApplicationStatus } from '../../lib/mock-data';
|
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 { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import {
|
import {
|
||||||
@ -16,16 +18,19 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Grid3x3,
|
Grid3x3,
|
||||||
List,
|
List,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
User as UserIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
TableCell
|
||||||
} from '../ui/table';
|
} from '../ui/table';
|
||||||
import { Progress } from '../ui/progress';
|
import { Progress } from '../ui/progress';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
@ -34,11 +39,27 @@ import { Label } from '../ui/label';
|
|||||||
import { Textarea } from '../ui/textarea';
|
import { Textarea } from '../ui/textarea';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ApplicationCard } from './ApplicationCard';
|
import { ApplicationCard } from './ApplicationCard';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '../ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
|
|
||||||
interface OpportunityRequestsPageProps {
|
interface OpportunityRequestsPageProps {
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
|
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -48,23 +69,101 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [showShortlistModal, setShowShortlistModal] = useState(false);
|
const [showShortlistModal, setShowShortlistModal] = useState(false);
|
||||||
const [shortlistRemark, setShortlistRemark] = useState('');
|
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)
|
// Filter applications that match preferred locations (opportunity requests)
|
||||||
// These are applications where we are currently offering dealerships
|
// These are applications where we are currently offering dealerships
|
||||||
// Shows applications shortlisted by DD but NOT yet shortlisted by DD Lead
|
// Shows applications shortlisted by DD but NOT yet shortlisted by DD Lead
|
||||||
// IMPORTANT: Only shows applications in early stages (before they enter full workflow)
|
// 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) => {
|
const filteredApplications = applicationsData.filter((app) => {
|
||||||
// Only show applications that are:
|
// Only show applications that are:
|
||||||
// 1. Shortlisted by DD (isShortlisted = true) - meaning it's an opportunity
|
// 1. Not Shortlisted by DD Lead yet (ddLeadShortlisted !== true) - waiting for action
|
||||||
// 2. NOT yet shortlisted by DD Lead (ddLeadShortlisted !== true) - waiting for DD Lead action
|
const waitingForDDLead = !(app as any).ddLeadShortlisted;
|
||||||
// 3. In early stages ONLY (Submitted, Questionnaire Pending, Questionnaire Completed)
|
|
||||||
const isOpportunity = app.isShortlisted === true && !(app as any).ddLeadShortlisted;
|
|
||||||
|
|
||||||
// Only show applications with early-stage statuses
|
// Only show applications with Opportunity statuses
|
||||||
const validStatuses: ApplicationStatus[] = ['Submitted', 'Questionnaire Pending', 'Questionnaire Completed'];
|
// 'Submitted' is EXCLUDED because it represents Non-Opportunity (Leads)
|
||||||
const isEarlyStage = validStatuses.includes(app.status);
|
const validStatuses: ApplicationStatus[] = ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'];
|
||||||
|
const isOpportunityStatus = validStatuses.includes(app.status);
|
||||||
|
|
||||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
app.registrationNumber.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 matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
||||||
const matchesState = stateFilter === 'all' || app.state === stateFilter;
|
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) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
@ -99,26 +198,26 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
setShowShortlistModal(true);
|
setShowShortlistModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmShortlist = () => {
|
const confirmShortlist = async () => {
|
||||||
if (!assigneeEmail.trim()) {
|
if (selectedAssignees.length === 0) {
|
||||||
toast.error('Please enter an email to assign the applications');
|
toast.error('Please assign at least one user');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email validation
|
const assignedUserIds = selectedAssignees.map(u => u.id);
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(assigneeEmail)) {
|
|
||||||
toast.error('Please enter a valid email address');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 => {
|
const updatedApplications = applicationsData.map(app => {
|
||||||
if (selectedIds.includes(app.id)) {
|
if (selectedIds.includes(app.id)) {
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
ddLeadShortlisted: true,
|
ddLeadShortlisted: true,
|
||||||
assignedTo: assigneeEmail
|
assignedTo: assignedUserIds[0] // Optimistically update with first assignee
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
return app;
|
return app;
|
||||||
@ -128,9 +227,16 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setShowShortlistModal(false);
|
setShowShortlistModal(false);
|
||||||
setShortlistRemark('');
|
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 = () => {
|
const handleBulkReminders = () => {
|
||||||
@ -174,12 +280,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
'Statutory Check': 'bg-emerald-100 text-emerald-800',
|
'Statutory Check': 'bg-emerald-100 text-emerald-800',
|
||||||
'Statutory Partnership': 'bg-emerald-100 text-emerald-800',
|
'Statutory Partnership': 'bg-emerald-100 text-emerald-800',
|
||||||
'Statutory Firm Reg': '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 Virtual Code': 'bg-emerald-100 text-emerald-800',
|
||||||
'Statutory Domain': 'bg-emerald-100 text-emerald-800',
|
'Statutory Domain': 'bg-emerald-100 text-emerald-800',
|
||||||
'Statutory MSD': 'bg-emerald-100 text-emerald-800',
|
'Statutory MSD': 'bg-emerald-100 text-emerald-800',
|
||||||
'Statutory LOI Ack': 'bg-emerald-100 text-emerald-800',
|
'Statutory LOI Ack': 'bg-emerald-100 text-emerald-800',
|
||||||
'EOR In Progress': 'bg-violet-100 text-violet-800',
|
'EOR In Progress': 'bg-violet-100 text-violet-800',
|
||||||
'LOA Pending': 'bg-pink-100 text-pink-800',
|
'LOA Pending': 'bg-pink-100 text-pink-800',
|
||||||
|
'Inauguration': 'bg-green-100 text-green-800',
|
||||||
'Approved': 'bg-green-100 text-green-800',
|
'Approved': 'bg-green-100 text-green-800',
|
||||||
'Rejected': 'bg-red-100 text-red-800',
|
'Rejected': 'bg-red-100 text-red-800',
|
||||||
'Disqualified': 'bg-gray-100 text-gray-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';
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Info Banner */}
|
{/* Info Banner */}
|
||||||
@ -421,27 +537,83 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shortlist Modal with Email Assignment */}
|
{/* Shortlist Modal with Multi-User Assignment */}
|
||||||
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
||||||
<DialogContent>
|
<DialogContent className="overflow-visible">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Shortlist & Assign Applications</DialogTitle>
|
<DialogTitle>Shortlist & Assign Applications</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Assign to User Email *</Label>
|
<Label>Assign to Users *</Label>
|
||||||
<Input
|
|
||||||
type="email"
|
{/* Selected Users Badges */}
|
||||||
placeholder="Enter email address to assign applications..."
|
<div className="flex flex-wrap gap-2 mb-2 p-2 border rounded-md min-h-[42px]">
|
||||||
value={assigneeEmail}
|
{(!selectedAssignees || selectedAssignees.length === 0) && <span className="text-slate-400 text-sm py-1">No users selected</span>}
|
||||||
onChange={(e) => setAssigneeEmail(e.target.value)}
|
{selectedAssignees?.map(user => (
|
||||||
className="mt-2"
|
user ? (
|
||||||
/>
|
<Badge key={user.id} variant="secondary" className="pl-2 pr-1 py-1 flex items-center gap-1">
|
||||||
<p className="text-slate-500 text-sm mt-1">The selected applications will be assigned to this user for processing</p>
|
{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>
|
</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>
|
<div>
|
||||||
<Label>Shortlisting Remark (Optional)</Label>
|
<Label>Shortlisting Remark (Optional)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@ -452,13 +624,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowShortlistModal(false);
|
setShowShortlistModal(false);
|
||||||
setAssigneeEmail('');
|
setSelectedAssignees([]);
|
||||||
setShortlistRemark('');
|
setShortlistRemark('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
116
src/components/applications/QuestionnaireResponseView.tsx
Normal file
116
src/components/applications/QuestionnaireResponseView.tsx
Normal 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;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mockApplications, locations, states } from '../../lib/mock-data';
|
import { mockApplications, locations, states, Application, ApplicationStatus } from '../../lib/mock-data';
|
||||||
|
import { onboardingService } from '../../services/onboarding.service';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import {
|
import {
|
||||||
@ -12,7 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
Database
|
Database,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -23,6 +25,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '../ui/table';
|
} from '../ui/table';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface UnopportunityRequestsPageProps {
|
interface UnopportunityRequestsPageProps {
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
@ -33,13 +36,71 @@ export function UnopportunityRequestsPage({ onViewDetails }: UnopportunityReques
|
|||||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||||
const [stateFilter, setStateFilter] = 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
|
// Filter unopportunity leads - These are lead generation submissions
|
||||||
// People who expressed interest but received unopportunity email because
|
// People who expressed interest but received unopportunity email because
|
||||||
// we're currently not offering dealerships in their preferred location
|
// we're currently not offering dealerships in their preferred location
|
||||||
const filteredLeads = mockApplications.filter((app) => {
|
// UPDATED LOGIC: 'Submitted' status specifically implies Non-Opportunity (Lead)
|
||||||
// Only show applications that have not been shortlisted by DD
|
const filteredLeads = applicationsData.filter((app) => {
|
||||||
// These are pure leads for future reference
|
// Only show applications with 'Submitted' status
|
||||||
const isUnopportunity = !app.isShortlisted;
|
const isUnopportunity = app.status === 'Submitted';
|
||||||
|
|
||||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Label } from '../ui/label';
|
|||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
import { AlertCircle, Copy, Check } from 'lucide-react';
|
import { AlertCircle, Copy, Check } from 'lucide-react';
|
||||||
import { mockUsers } from '../../lib/mock-data';
|
import { mockUsers } from '../../lib/mock-data';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
onLogin: (email: string, password: string) => void;
|
onLogin: (email: string, password: string) => void;
|
||||||
@ -62,8 +63,10 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await onLogin(userEmail, userPassword);
|
await onLogin(userEmail, userPassword);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError('Auto-login failed');
|
const msg = err.response?.data?.message || err.message || 'Auto-login failed';
|
||||||
|
setError(msg);
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -83,8 +86,10 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await onLogin(email, password);
|
await onLogin(email, password);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError('An unexpected error occurred');
|
const msg = err.response?.data?.message || err.message || 'Login failed';
|
||||||
|
setError(msg);
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,9 @@ interface Question {
|
|||||||
id: string;
|
id: string;
|
||||||
sectionName: string;
|
sectionName: string;
|
||||||
questionText: string;
|
questionText: string;
|
||||||
inputType: 'text' | 'yesno' | 'file' | 'number';
|
inputType: 'text' | 'yesno' | 'file' | 'number' | 'select' | 'mcq';
|
||||||
options?: any;
|
options?: any;
|
||||||
|
questionOptions?: any[]; // From backend inclusion
|
||||||
weight: number;
|
weight: number;
|
||||||
order: number;
|
order: number;
|
||||||
isMandatory: boolean;
|
isMandatory: boolean;
|
||||||
@ -59,8 +60,14 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await API.getLatestQuestionnaire();
|
const res = await API.getLatestQuestionnaire();
|
||||||
|
|
||||||
if (res.data && res.data.data && res.data.data.questions) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -75,8 +82,25 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
|
|||||||
setResponses(prev => ({ ...prev, [questionId]: value }));
|
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 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) {
|
if (missing.length > 0) {
|
||||||
toast.error(`Please answer all mandatory questions. Missing: ${missing.length}`);
|
toast.error(`Please answer all mandatory questions. Missing: ${missing.length}`);
|
||||||
return;
|
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' && (
|
{q.inputType === 'number' && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -154,28 +202,32 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.inputType === 'yesno' && (
|
{(q.inputType === 'yesno' || q.inputType === 'select' || q.inputType === 'mcq') && (
|
||||||
<div className="flex gap-4">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name={`q-${q.id}`}
|
name={`q-${q.id}`}
|
||||||
value="yes"
|
value={val}
|
||||||
checked={responses[q.id] === 'yes'}
|
checked={responses[q.id] === val}
|
||||||
onChange={() => handleInputChange(q.id, 'yes')}
|
onChange={() => handleInputChange(q.id, val)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/> Yes
|
className="text-amber-600 focus:ring-amber-500 w-4 h-4"
|
||||||
</label>
|
/>
|
||||||
<label className="flex items-center gap-2">
|
<span className="text-gray-700">{val}</span>
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`q-${q.id}`}
|
|
||||||
value="no"
|
|
||||||
checked={responses[q.id] === 'no'}
|
|
||||||
onChange={() => handleInputChange(q.id, 'no')}
|
|
||||||
disabled={readOnly}
|
|
||||||
/> No
|
|
||||||
</label>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add All Requests for DD Lead role (before Dealership Requests)
|
// 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, {
|
menuItems.splice(1, 0, {
|
||||||
id: 'all-requests',
|
id: 'all-requests',
|
||||||
label: 'All Requests',
|
label: 'All Requests',
|
||||||
|
|||||||
@ -102,6 +102,7 @@ export interface Application {
|
|||||||
architectureDocumentDate?: string;
|
architectureDocumentDate?: string;
|
||||||
architectureCompletionDate?: string;
|
architectureCompletionDate?: string;
|
||||||
inaugurationDate?: string;
|
inaugurationDate?: string;
|
||||||
|
questionnaireResponses?: any[]; // added for response view
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@ -202,6 +203,13 @@ export const mockUsers: User[] = [
|
|||||||
password: 'Admin@123',
|
password: 'Admin@123',
|
||||||
role: 'DD Lead',
|
role: 'DD Lead',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '18',
|
||||||
|
name: 'Lince',
|
||||||
|
email: 'lince@gmail.com',
|
||||||
|
password: 'Admin@123',
|
||||||
|
role: 'DD Admin',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock current user (default)
|
// Mock current user (default)
|
||||||
|
|||||||
@ -23,14 +23,38 @@ const PublicQuestionnairePage: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setIsValid(false);
|
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);
|
setIsValid(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
checkValidity();
|
checkValidity();
|
||||||
}, [applicationId]);
|
}, [applicationId]);
|
||||||
|
|
||||||
if (isValid === null) return <div className="p-8 text-center">Checking application link...</div>;
|
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>;
|
if (isValid === false) return <div className="p-8 text-center text-red-500 text-xl font-bold">Invalid or Expired Link</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -19,6 +19,15 @@ export const onboardingService = {
|
|||||||
throw error;
|
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) => {
|
getApplicationById: async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await API.getApplicationById(id);
|
const response = await API.getApplicationById(id);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user