1.1.1.0
This commit is contained in:
parent
30954f0f9a
commit
d9a7426dfa
@ -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 }),
|
||||
|
||||
130
backend/src/routes/voterscope.js
Normal file
130
backend/src/routes/voterscope.js
Normal 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;
|
||||
127
frontend/src/components/ExportButtons.tsx
Normal file
127
frontend/src/components/ExportButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
485
frontend/src/pages/voterscope-ai.tsx
Normal file
485
frontend/src/pages/voterscope-ai.tsx
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user