This commit is contained in:
Flatlogic Bot 2026-06-09 15:12:38 +00:00
parent 30954f0f9a
commit d9a7426dfa
8 changed files with 873 additions and 150 deletions

View File

@ -37,6 +37,8 @@ const search_queriesRoutes = require('./routes/search_queries');
const export_jobsRoutes = require('./routes/export_jobs');
const voterscopeRoutes = require('./routes/voterscope');
const getBaseUrl = (url) => {
if (!url) return '';
@ -111,6 +113,8 @@ app.use('/api/search_queries', passport.authenticate('jwt', {session: false}), s
app.use('/api/export_jobs', passport.authenticate('jwt', {session: false}), export_jobsRoutes);
app.use('/api/voterscope', passport.authenticate('jwt', {session: false}), voterscopeRoutes);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,130 @@
const express = require('express');
const db = require('../db/models');
const { wrapAsync } = require('../helpers');
const router = express.Router();
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
const searchableFields = [
'voter_number',
'national_id_number',
'name_bn',
'name_en',
'father_name',
'mother_name',
'spouse_name',
'address_line',
'district',
'upazila',
'union_ward',
'village_mohalla',
'polling_center',
'raw_text_snippet',
];
function cleanText(value, maxLength = 120) {
if (!value || typeof value !== 'string') return '';
return value.trim().slice(0, maxLength);
}
function buildSearchWhere(query) {
const q = cleanText(query.q);
const mode = ['all', 'name', 'voter'].includes(query.mode) ? query.mode : 'all';
const where = {};
if (q) {
if (mode === 'name') {
where[Op.or] = [
{ name_bn: { [Op.iLike]: `%${q}%` } },
{ name_en: { [Op.iLike]: `%${q}%` } },
{ father_name: { [Op.iLike]: `%${q}%` } },
{ mother_name: { [Op.iLike]: `%${q}%` } },
{ spouse_name: { [Op.iLike]: `%${q}%` } },
];
} else if (mode === 'voter') {
where[Op.or] = [
{ voter_number: { [Op.iLike]: `%${q}%` } },
{ national_id_number: { [Op.iLike]: `%${q}%` } },
];
} else {
where[Op.or] = searchableFields.map((field) => ({
[field]: { [Op.iLike]: `%${q}%` },
}));
}
}
const district = cleanText(query.district, 80);
const upazila = cleanText(query.upazila, 80);
const gender = cleanText(query.gender, 20);
if (district) where.district = { [Op.iLike]: `%${district}%` };
if (upazila) where.upazila = { [Op.iLike]: `%${upazila}%` };
if (['male', 'female', 'other', 'unknown'].includes(gender)) where.gender = gender;
return where;
}
router.get('/summary', wrapAsync(async (req, res) => {
const [totalRecords, totalPdfs, maleRecords, femaleRecords, areaRows, latestPdfs] = await Promise.all([
db.voter_records.count(),
db.pdf_documents.count(),
db.voter_records.count({ where: { gender: 'male' } }),
db.voter_records.count({ where: { gender: 'female' } }),
db.voter_records.findAll({
attributes: [
[Sequelize.fn('COALESCE', Sequelize.col('district'), 'অনির্ধারিত'), 'area'],
[Sequelize.fn('COUNT', Sequelize.col('voter_records.id')), 'count'],
],
group: [Sequelize.fn('COALESCE', Sequelize.col('district'), 'অনির্ধারিত')],
order: [[Sequelize.literal('count'), 'DESC']],
limit: 5,
raw: true,
}),
db.pdf_documents.findAll({
attributes: ['id', 'document_name', 'processing_status', 'uploaded_at', 'createdAt'],
order: [['createdAt', 'DESC']],
limit: 6,
raw: true,
}),
]);
res.status(200).send({
totals: {
voters: totalRecords,
pdfs: totalPdfs,
male: maleRecords,
female: femaleRecords,
},
areas: areaRows.map((row) => ({ area: row.area, count: Number(row.count) })),
latestPdfs,
});
}));
router.get('/search', wrapAsync(async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 25, 100);
const page = Math.max(Number(req.query.page) || 0, 0);
const where = buildSearchWhere(req.query);
const payload = await db.voter_records.findAndCountAll({
where,
include: [
{
model: db.pdf_documents,
as: 'source_document',
attributes: ['id', 'document_name', 'processing_status'],
required: false,
},
],
distinct: true,
order: [['createdAt', 'DESC']],
limit,
offset: page * limit,
});
res.status(200).send({ rows: payload.rows, count: payload.count });
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,127 @@
import React from 'react';
import { mdiFileDelimitedOutline, mdiFilePdfBox } from '@mdi/js';
import BaseButton from './BaseButton';
import BaseButtons from './BaseButtons';
type ExportColumn<T> = {
key: keyof T | string;
label: string;
render?: (row: T) => string | number | null | undefined;
};
type Props<T> = {
rows: T[];
columns: ExportColumn<T>[];
filename?: string;
targetId?: string;
};
const csvEscape = (value: unknown) => {
const safeValue = value === null || value === undefined ? '' : String(value);
return `"${safeValue.replace(/"/g, '""')}"`;
};
export default function ExportButtons<T>({
rows,
columns,
filename = 'voterscope-results',
targetId,
}: Props<T>) {
const [isExportingPdf, setIsExportingPdf] = React.useState(false);
const hasRows = rows.length > 0;
const valueFor = (row: T, column: ExportColumn<T>) => {
if (column.render) return column.render(row);
return row[column.key as keyof T] as unknown;
};
const exportCsv = () => {
if (!hasRows) return;
const header = columns.map((column) => csvEscape(column.label)).join(',');
const body = rows
.map((row) => columns.map((column) => csvEscape(valueFor(row, column))).join(','))
.join('\n');
const blob = new Blob([`\uFEFF${header}\n${body}`], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const exportPdf = async () => {
if (!hasRows) return;
const target = targetId ? document.getElementById(targetId) : null;
if (!target) {
console.error(`Export target was not found: ${targetId}`);
return;
}
setIsExportingPdf(true);
try {
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(target, {
backgroundColor: '#ffffff',
scale: 2,
useCORS: true,
});
const image = canvas.toDataURL('image/png');
const printWindow = window.open('', '_blank');
if (!printWindow) {
console.error('Unable to open print window for PDF export.');
return;
}
printWindow.document.write(`
<html lang="bn">
<head>
<title>${filename}</title>
<style>
body { margin: 0; font-family: "Noto Sans Bengali", "Hind Siliguri", Arial, sans-serif; background: #fff; }
img { width: 100%; display: block; }
@page { margin: 16mm; }
</style>
</head>
<body>
<img src="${image}" alt="VoterScope AI export" />
<script>window.onload = function () { window.print(); };</script>
</body>
</html>
`);
printWindow.document.close();
} catch (error) {
console.error('PDF export failed', error);
} finally {
setIsExportingPdf(false);
}
};
return (
<BaseButtons noWrap>
<BaseButton
label='CSV Export'
icon={mdiFileDelimitedOutline}
color='success'
small
outline
disabled={!hasRows}
onClick={exportCsv}
/>
<BaseButton
label={isExportingPdf ? 'Preparing PDF' : 'PDF Export'}
icon={mdiFilePdfBox}
color='danger'
small
outline
disabled={!hasRows || isExportingPdf}
onClick={exportPdf}
/>
</BaseButtons>
);
}

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/voterscope-ai',
label: 'VoterScope AI',
icon: icon.mdiAccountSearch,
},
{
href: '/users/users-list',

View File

@ -1,166 +1,139 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import React, { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import { mdiAccountSearch, mdiFileExportOutline, mdiFilePdfBox, mdiLoginVariant, mdiShieldLockOutline } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const featureCards = [
{
icon: mdiFilePdfBox,
title: 'একাধিক PDF আপলোড',
copy: 'ভোটার-লিস্ট PDF একবার যোগ করলে তথ্য সিস্টেমে সেভ থাকে।',
},
{
icon: mdiAccountSearch,
title: 'বাংলা/ইংরেজি সার্চ',
copy: 'নাম, ভোটার নং, ঠিকানা বা সেভ করা টেক্সট দিয়ে দ্রুত খুঁজুন।',
},
{
icon: mdiFileExportOutline,
title: 'CSV + PDF এক্সপোর্ট',
copy: 'বাংলা লেখা অক্ষত রেখে ফলাফল ডাউনলোড বা প্রিন্ট-টু-PDF করুন।',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'VoterScope AI'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('VoterScope AI')}</title>
<meta name='description' content='Authenticated voter PDF search dashboard for Bengali and English records.' />
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your VoterScope AI app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main className='min-h-screen bg-[#F5FBFF] text-slate-900'>
<header className='mx-auto flex max-w-7xl items-center justify-between px-6 py-6'>
<Link href='/' className='flex items-center gap-3 font-black tracking-tight'>
<span className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[#0B5CAD] text-white shadow-lg shadow-blue-200'>
<BaseIcon path={mdiAccountSearch} size={24} />
</span>
<span className='text-xl'>VoterScope AI</span>
</Link>
<nav className='flex items-center gap-3'>
<Link href='/login' className='rounded-full px-4 py-2 text-sm font-bold text-slate-700 hover:bg-white'>
Login
</Link>
<Link href='/voterscope-ai' className='rounded-full bg-[#071B3A] px-5 py-2 text-sm font-bold text-white shadow-lg shadow-slate-300'>
Admin Interface
</Link>
</nav>
</header>
<section className='mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 pb-16 pt-8 lg:grid-cols-2 lg:items-center'>
<div>
<div className='mb-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-bold text-[#0B5CAD] shadow-sm ring-1 ring-cyan-100'>
<BaseIcon path={mdiShieldLockOutline} size={18} />
Only logged-in users can search voter PDFs
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<h1 className='max-w-3xl text-5xl font-black leading-tight tracking-tight md:text-7xl'>
Authenticated voter-PDF search for & English data.
</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
VoterScope AI gives admins a polished PDF upload center and gives viewers a fast dashboard to search, filter, and export reusable voter result lists.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<Link href='/login' className='inline-flex items-center gap-2 rounded-full bg-[#0B5CAD] px-6 py-3 font-black text-white shadow-xl shadow-blue-200 hover:bg-[#074986]'>
<BaseIcon path={mdiLoginVariant} size={20} />
Login to use
</Link>
<Link href='/register' className='inline-flex items-center rounded-full bg-white px-6 py-3 font-black text-slate-700 shadow-sm ring-1 ring-slate-200 hover:ring-cyan-300'>
Create account
</Link>
</div>
</div>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className='relative'>
<div className='absolute -left-8 -top-8 h-40 w-40 rounded-full bg-cyan-300 blur-3xl' />
<div className='absolute -bottom-10 right-0 h-48 w-48 rounded-full bg-orange-300 blur-3xl' />
<div className='relative overflow-hidden rounded-[2rem] bg-gradient-to-br from-[#071B3A] via-[#0B5CAD] to-[#06B6D4] p-5 text-white shadow-2xl'>
<div className='rounded-[1.5rem] bg-white/10 p-5 ring-1 ring-white/20'>
<div className='mb-6 flex items-center justify-between'>
<div>
<p className='text-sm text-cyan-100'>Live workflow preview</p>
<h2 className='text-2xl font-black'> </h2>
</div>
<span className='rounded-full bg-white px-3 py-1 text-xs font-black text-[#071B3A]'>Admin/Viewer</span>
</div>
<div className='rounded-2xl bg-white p-4 text-slate-900 shadow-xl'>
<div className='mb-3 flex gap-2'>
{['সব', 'নাম', 'ভোটার নং'].map((tab, index) => (
<span key={tab} className={`rounded-full px-3 py-1 text-xs font-bold ${index === 0 ? 'bg-[#0B5CAD] text-white' : 'bg-slate-100 text-slate-500'}`}>{tab}</span>
))}
</div>
<div className='rounded-2xl border border-slate-200 p-4 text-slate-400'> / / ি</div>
<div className='mt-4 space-y-3'>
{[82, 64, 48].map((width, index) => (
<div key={width} className='rounded-xl bg-slate-50 p-3'>
<div className='h-3 rounded-full bg-slate-200' style={{ width: `${width}%` }} />
<div className='mt-2 h-2 rounded-full bg-cyan-100' style={{ width: `${width - index * 8}%` }} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<section className='mx-auto grid max-w-7xl grid-cols-1 gap-4 px-6 pb-16 md:grid-cols-3'>
{featureCards.map((feature) => (
<div key={feature.title} className='rounded-3xl bg-white p-6 shadow-sm ring-1 ring-cyan-100'>
<div className='mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-50 text-[#0B5CAD]'>
<BaseIcon path={feature.icon} size={26} />
</div>
<h3 className='text-xl font-black'>{feature.title}</h3>
<p className='mt-2 text-sm leading-6 text-slate-600'>{feature.copy}</p>
</div>
))}
</section>
<footer className='border-t border-cyan-100 bg-white/70 px-6 py-6'>
<div className='mx-auto flex max-w-7xl flex-col gap-3 text-sm text-slate-500 md:flex-row md:items-center md:justify-between'>
<p>© {new Date().getFullYear()} VoterScope AI</p>
<div className='flex flex-wrap gap-4'>
<Link href='/privacy-policy' className='hover:text-[#0B5CAD]'> ি</Link>
<Link href='/terms-of-use' className='hover:text-[#0B5CAD]'></Link>
<Link href='/login' className='hover:text-[#0B5CAD]'></Link>
<Link href='/' className='hover:text-[#0B5CAD]'> </Link>
</div>
</div>
</footer>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,485 @@
import React, { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import axios from 'axios';
import {
mdiAccountSearch,
mdiChartDonut,
mdiChevronDown,
mdiChevronUp,
mdiCloudUploadOutline,
mdiFilePdfBox,
mdiMagnify,
mdiShieldAccount,
mdiViewDashboardOutline,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import ExportButtons from '../components/ExportButtons';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import FileUploader from '../components/Uploaders/UploadService';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import { useAppSelector } from '../stores/hooks';
type VoterRecord = {
id: string;
voter_number?: string;
national_id_number?: string;
name_bn?: string;
name_en?: string;
gender?: string;
age?: number;
father_name?: string;
mother_name?: string;
address_line?: string;
district?: string;
upazila?: string;
union_ward?: string;
polling_center?: string;
raw_text_snippet?: string;
source_document?: {
document_name?: string;
processing_status?: string;
};
};
type Summary = {
totals: {
voters: number;
pdfs: number;
male: number;
female: number;
};
areas: Array<{ area: string; count: number }>;
latestPdfs: Array<{
id: string;
document_name: string;
processing_status?: string;
uploaded_at?: string;
createdAt?: string;
}>;
};
const searchTabs = [
{ key: 'all', label: 'সব' },
{ key: 'name', label: 'নাম' },
{ key: 'voter', label: 'ভোটার নং' },
];
const exportColumns = [
{ key: 'voter_number', label: 'ভোটার নং' },
{ key: 'name_bn', label: 'নাম (বাংলা)' },
{ key: 'name_en', label: 'Name (English)' },
{ key: 'gender', label: 'লিঙ্গ' },
{ key: 'age', label: 'বয়স' },
{ key: 'father_name', label: 'পিতার নাম' },
{ key: 'mother_name', label: 'মাতার নাম' },
{ key: 'address_line', label: 'ঠিকানা' },
{ key: 'district', label: 'জেলা' },
{ key: 'upazila', label: 'উপজেলা' },
{ key: 'polling_center', label: 'কেন্দ্র' },
{
key: 'source_document',
label: 'PDF',
render: (row: VoterRecord) => row.source_document?.document_name || '',
},
];
const emptySummary: Summary = {
totals: { voters: 0, pdfs: 0, male: 0, female: 0 },
areas: [],
latestPdfs: [],
};
const formatNumber = (value: number) => new Intl.NumberFormat('bn-BD').format(value || 0);
const VoterScopeAi = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [summary, setSummary] = React.useState<Summary>(emptySummary);
const [summaryLoading, setSummaryLoading] = React.useState(true);
const [query, setQuery] = React.useState('');
const [tab, setTab] = React.useState('all');
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [district, setDistrict] = React.useState('');
const [upazila, setUpazila] = React.useState('');
const [gender, setGender] = React.useState('');
const [results, setResults] = React.useState<VoterRecord[]>([]);
const [resultCount, setResultCount] = React.useState(0);
const [searchLoading, setSearchLoading] = React.useState(false);
const [searchError, setSearchError] = React.useState('');
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
const [uploading, setUploading] = React.useState(false);
const [uploadMessage, setUploadMessage] = React.useState('');
const [uploadError, setUploadError] = React.useState('');
const [isDragging, setIsDragging] = React.useState(false);
const roleName = currentUser?.app_role?.name || 'Viewer';
const isAdmin = roleName === 'Administrator';
const canUpload = isAdmin || hasPermission(currentUser, 'CREATE_PDF_DOCUMENTS');
const maxAreaCount = Math.max(...summary.areas.map((area) => area.count), 1);
const loadSummary = React.useCallback(async () => {
setSummaryLoading(true);
try {
const response = await axios.get('voterscope/summary');
setSummary(response.data || emptySummary);
} catch (error) {
console.error('Failed to load VoterScope summary', error);
} finally {
setSummaryLoading(false);
}
}, []);
const runSearch = React.useCallback(async () => {
setSearchLoading(true);
setSearchError('');
try {
const response = await axios.get('voterscope/search', {
params: {
q: query,
mode: tab,
district,
upazila,
gender,
limit: 50,
},
});
setResults(response.data?.rows || []);
setResultCount(response.data?.count || 0);
} catch (error) {
console.error('Voter search failed', error);
setSearchError('সার্চ করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।');
} finally {
setSearchLoading(false);
}
}, [district, gender, query, tab, upazila]);
React.useEffect(() => {
loadSummary();
runSearch();
}, [loadSummary, runSearch]);
const addFiles = (files: FileList | null) => {
if (!files) return;
const pdfFiles = Array.from(files).filter((file) => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
if (!pdfFiles.length) {
setUploadError('শুধু PDF ফাইল আপলোড করা যাবে।');
return;
}
setUploadError('');
setUploadMessage('');
setSelectedFiles((current) => [...current, ...pdfFiles]);
};
const uploadFiles = async () => {
if (!selectedFiles.length || !canUpload) return;
setUploading(true);
setUploadError('');
setUploadMessage('');
try {
for (const file of selectedFiles) {
const uploadedFile = await FileUploader.upload('pdf_documents/pdf_file', file, {
formats: ['pdf'],
size: 25 * 1024 * 1024,
});
await axios.post('pdf_documents', {
data: {
document_name: file.name,
source_language_hint: 'বাংলা / English',
processing_status: 'indexed',
uploaded_at: new Date().toISOString(),
pdf_file: [{ ...uploadedFile }],
uploaded_by: currentUser?.id,
},
});
}
setUploadMessage(`${formatNumber(selectedFiles.length)} টি PDF সেভ হয়েছে।`);
setSelectedFiles([]);
await loadSummary();
} catch (error) {
console.error('PDF upload failed', error);
setUploadError('PDF আপলোড ব্যর্থ হয়েছে। ফাইলের আকার/অনুমতি যাচাই করুন।');
} finally {
setUploading(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('VoterScope AI')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiAccountSearch} title='VoterScope AI' main>
<BaseButton href='/dashboard' label='Dashboard Toggle' icon={mdiViewDashboardOutline} color='info' outline />
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl border border-cyan-100 bg-gradient-to-br from-[#071B3A] via-[#0B5CAD] to-[#06B6D4] p-6 text-white shadow-xl'>
<div className='flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between'>
<div>
<div className='mb-3 inline-flex items-center rounded-full bg-white/15 px-4 py-1 text-sm font-semibold ring-1 ring-white/20'>
িি ি
</div>
<h2 className='text-4xl font-black tracking-tight'>VoterScope AI</h2>
<p className='mt-3 max-w-2xl text-sm leading-6 text-cyan-50'>
/ি , PDF CSV/PDF
</p>
</div>
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
<div className='rounded-2xl bg-white/15 p-4 ring-1 ring-white/20'>
<p className='text-xs text-cyan-100'> </p>
<p className='text-2xl font-black'>{summaryLoading ? '…' : formatNumber(summary.totals.voters)}</p>
</div>
<div className='rounded-2xl bg-white/15 p-4 ring-1 ring-white/20'>
<p className='text-xs text-cyan-100'>PDF </p>
<p className='text-2xl font-black'>{summaryLoading ? '…' : formatNumber(summary.totals.pdfs)}</p>
</div>
<div className='col-span-2 rounded-2xl bg-white p-4 text-[#071B3A] sm:col-span-1'>
<div className='flex items-center gap-2 text-sm font-bold'>
<BaseIcon path={mdiShieldAccount} size={20} />
{isAdmin ? 'অ্যাডমিন' : 'ভিউয়ার'}
</div>
<p className='mt-1 text-xs text-slate-500'>{currentUser?.email || roleName}</p>
</div>
</div>
</div>
</div>
<div className='grid grid-cols-1 gap-6 xl:grid-cols-3'>
<div className='space-y-6 xl:col-span-2'>
<CardBox className='border-0 shadow-lg'>
<div className='mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
<div>
<span className='inline-flex rounded-full bg-cyan-100 px-3 py-1 text-xs font-bold text-cyan-700'> </span>
<h3 className='mt-2 text-2xl font-black text-slate-900 dark:text-white'> </h3>
</div>
<ExportButtons<VoterRecord> rows={results} columns={exportColumns} filename='voterscope-search-results' targetId='voterscope-export-area' />
</div>
<div className='mb-4 flex flex-wrap gap-2'>
{searchTabs.map((item) => (
<button
key={item.key}
type='button'
onClick={() => setTab(item.key)}
className={`rounded-full px-4 py-2 text-sm font-bold transition ${
tab === item.key
? 'bg-[#0B5CAD] text-white shadow-md'
: 'bg-slate-100 text-slate-600 hover:bg-cyan-50 hover:text-cyan-700 dark:bg-slate-800 dark:text-slate-200'
}`}
>
{item.label}
</button>
))}
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<div className='relative flex-1'>
<BaseIcon path={mdiMagnify} size={22} className='pointer-events-none absolute left-3 top-3 text-slate-400' />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && runSearch()}
className='h-12 w-full rounded-2xl border border-slate-200 bg-white pl-11 pr-4 text-slate-900 shadow-sm outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='নাম, ভোটার নং, ঠিকানা বা PDF টেক্সট লিখুন...'
/>
</div>
<BaseButton label='Search' icon={mdiMagnify} color='info' className='h-12 md:min-w-32' onClick={runSearch} disabled={searchLoading} />
</div>
<button
type='button'
onClick={() => setShowAdvanced((value) => !value)}
className='mt-4 flex w-full items-center justify-between rounded-2xl bg-slate-50 px-4 py-3 text-left text-sm font-bold text-slate-700 hover:bg-cyan-50 dark:bg-slate-800 dark:text-slate-100'
>
ি
<BaseIcon path={showAdvanced ? mdiChevronUp : mdiChevronDown} size={20} />
</button>
{showAdvanced && (
<div className='mt-4 grid grid-cols-1 gap-3 rounded-2xl border border-slate-100 bg-white p-4 shadow-inner dark:border-slate-700 dark:bg-slate-900 md:grid-cols-3'>
<input className='rounded-xl border border-slate-200 px-3 py-2 dark:border-slate-700 dark:bg-slate-800' placeholder='জেলা' value={district} onChange={(event) => setDistrict(event.target.value)} />
<input className='rounded-xl border border-slate-200 px-3 py-2 dark:border-slate-700 dark:bg-slate-800' placeholder='উপজেলা' value={upazila} onChange={(event) => setUpazila(event.target.value)} />
<select className='rounded-xl border border-slate-200 px-3 py-2 dark:border-slate-700 dark:bg-slate-800' value={gender} onChange={(event) => setGender(event.target.value)}>
<option value=''> ি</option>
<option value='male'></option>
<option value='female'>ি</option>
<option value='other'></option>
<option value='unknown'></option>
</select>
</div>
)}
{searchError && <div className='mt-4 rounded-xl bg-red-50 p-3 text-sm font-semibold text-red-700'>{searchError}</div>}
</CardBox>
<CardBox className='border-0 shadow-lg'>
<div id='voterscope-export-area' className='bg-white p-1 text-slate-900'>
<div className='mb-4 flex items-center justify-between'>
<div>
<h3 className='text-xl font-black'> </h3>
<p className='text-sm text-slate-500'>{searchLoading ? 'লোড হচ্ছে…' : `${formatNumber(resultCount)} টি রেকর্ড পাওয়া গেছে`}</p>
</div>
<span className='rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700'>CSV/PDF ready</span>
</div>
{results.length === 0 && !searchLoading ? (
<div className='rounded-3xl border border-dashed border-slate-200 p-10 text-center'>
<BaseIcon path={mdiAccountSearch} size={42} className='mx-auto mb-3 text-slate-300' />
<p className='font-bold'> </p>
<p className='mt-1 text-sm text-slate-500'> ি ি </p>
</div>
) : (
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-slate-100 text-left text-sm'>
<thead className='bg-slate-50 text-xs uppercase text-slate-500'>
<tr>
<th className='px-4 py-3'></th>
<th className='px-4 py-3'> </th>
<th className='px-4 py-3'></th>
<th className='px-4 py-3'>ি</th>
<th className='px-4 py-3'>PDF</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-100'>
{results.map((record) => (
<tr key={record.id} className='hover:bg-cyan-50/50'>
<td className='px-4 py-3'>
<p className='font-black text-slate-900'>{record.name_bn || record.name_en || 'নাম নেই'}</p>
<p className='text-xs text-slate-500'>{record.father_name ? `পিতা: ${record.father_name}` : record.raw_text_snippet}</p>
</td>
<td className='px-4 py-3 font-semibold'>{record.voter_number || record.national_id_number || '—'}</td>
<td className='px-4 py-3'>{[record.district, record.upazila, record.union_ward].filter(Boolean).join(', ') || '—'}</td>
<td className='px-4 py-3'>{record.gender || '—'}</td>
<td className='px-4 py-3'>{record.source_document?.document_name || 'Saved record'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</CardBox>
</div>
<div className='space-y-6'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-1'>
{[
['মোট ভোটার', summary.totals.voters, 'from-[#2563EB] to-[#06B6D4]'],
['পুরুষ', summary.totals.male, 'from-[#10B981] to-[#84CC16]'],
['মহিলা', summary.totals.female, 'from-[#F97316] to-[#EC4899]'],
].map(([label, value, gradient]) => (
<div key={String(label)} className={`rounded-3xl bg-gradient-to-br ${gradient} p-5 text-white shadow-lg`}>
<div className='flex items-center justify-between'>
<p className='text-sm font-semibold text-white/80'>{label}</p>
<BaseIcon path={mdiChartDonut} size={24} className='text-white/80' />
</div>
<p className='mt-3 text-3xl font-black'>{summaryLoading ? '…' : formatNumber(Number(value))}</p>
</div>
))}
</div>
<CardBox className='border-0 shadow-lg'>
<h3 className='mb-4 text-xl font-black'>PDF </h3>
<div
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
addFiles(event.dataTransfer.files);
}}
className={`rounded-3xl border-2 border-dashed p-6 text-center transition ${
isDragging ? 'border-cyan-500 bg-cyan-50' : 'border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800'
} ${!canUpload ? 'opacity-60' : ''}`}
>
<BaseIcon path={mdiCloudUploadOutline} size={44} className='mx-auto mb-3 text-cyan-600' />
<p className='font-black'>Drag & drop multiple PDFs</p>
<p className='mt-1 text-xs text-slate-500'> PDF ি </p>
<label className='mt-4 inline-flex cursor-pointer rounded-full bg-[#0B5CAD] px-4 py-2 text-sm font-bold text-white shadow-md hover:bg-[#074986]'>
PDF
<input type='file' multiple accept='application/pdf,.pdf' className='hidden' disabled={!canUpload || uploading} onChange={(event) => addFiles(event.target.files)} />
</label>
</div>
{!canUpload && <p className='mt-3 text-sm font-semibold text-amber-600'> ি CREATE_PDF_DOCUMENTS ি PDF </p>}
{selectedFiles.length > 0 && (
<div className='mt-4 space-y-2'>
{selectedFiles.map((file) => (
<div key={`${file.name}-${file.size}`} className='flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2 text-sm dark:bg-slate-800'>
<span className='line-clamp-1 font-semibold'>{file.name}</span>
<span className='text-xs text-slate-500'>{Math.ceil(file.size / 1024)} KB</span>
</div>
))}
<BaseButton label={uploading ? 'Saving PDFs…' : 'Save PDFs'} color='success' icon={mdiFilePdfBox} className='w-full' disabled={uploading || !canUpload} onClick={uploadFiles} />
</div>
)}
{uploadMessage && <div className='mt-3 rounded-xl bg-emerald-50 p-3 text-sm font-bold text-emerald-700'>{uploadMessage}</div>}
{uploadError && <div className='mt-3 rounded-xl bg-red-50 p-3 text-sm font-bold text-red-700'>{uploadError}</div>}
</CardBox>
<CardBox className='border-0 shadow-lg'>
<h3 className='mb-4 text-xl font-black'>িি </h3>
<div className='space-y-4'>
{summary.areas.length === 0 ? (
<p className='rounded-2xl bg-slate-50 p-4 text-sm text-slate-500'> </p>
) : (
summary.areas.map((area) => (
<div key={area.area}>
<div className='mb-1 flex justify-between text-sm font-bold'>
<span>{area.area}</span>
<span>{formatNumber(area.count)}</span>
</div>
<div className='h-3 rounded-full bg-slate-100'>
<div className='h-3 rounded-full bg-gradient-to-r from-cyan-500 to-blue-600' style={{ width: `${Math.max((area.count / maxAreaCount) * 100, 8)}%` }} />
</div>
</div>
))
)}
</div>
</CardBox>
<CardBox className='border-0 shadow-lg'>
<h3 className='mb-4 text-xl font-black'> PDF ি</h3>
<div className='space-y-3'>
{summary.latestPdfs.length === 0 ? (
<p className='rounded-2xl bg-slate-50 p-4 text-sm text-slate-500'> PDF ি</p>
) : (
summary.latestPdfs.map((pdf) => (
<div key={pdf.id} className='rounded-2xl border border-slate-100 p-3 dark:border-slate-700'>
<p className='line-clamp-1 font-black'>{pdf.document_name}</p>
<p className='mt-1 text-xs text-slate-500'>Status: {pdf.processing_status || 'saved'} · {new Date(pdf.uploaded_at || pdf.createdAt).toLocaleDateString('bn-BD')}</p>
</div>
))
)}
</div>
</CardBox>
</div>
</div>
<footer className='mt-8 flex flex-col gap-3 rounded-3xl bg-slate-900 px-6 py-5 text-sm text-slate-300 md:flex-row md:items-center md:justify-between'>
<p>© {new Date().getFullYear()} VoterScope AI. ি</p>
<div className='flex flex-wrap gap-4'>
<Link href='/privacy-policy' className='hover:text-white'> ি</Link>
<Link href='/terms-of-use' className='hover:text-white'></Link>
<Link href='/dashboard' className='hover:text-white'></Link>
<Link href='/' className='hover:text-white'> </Link>
</div>
</footer>
</SectionMain>
</>
);
};
VoterScopeAi.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default VoterScopeAi;