feat: отчеты для клерка

This commit is contained in:
2025-05-27 18:20:55 +04:00
parent 2b24a8d6f2
commit b8a9409dad
14 changed files with 1253 additions and 25 deletions

View File

@@ -76,20 +76,20 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
{
for (int i = 0; i < program.ClientSurname.Count; i++)
{
tableRows.Add(new string[]
{
tableRows.Add(
[
program.CreditProgramName,
program.ClientSurname[i],
program.ClientName[i],
program.ClientBalance[i].ToString("N2")
});
]);
}
}
return _baseWordBuilder
.AddHeader("Клиенты по кредитным программам")
.AddParagraph($"Сформировано на дату {DateTime.Now}")
.AddTable([100, 100, 100, 100], tableRows)
.AddTable([2000, 2000, 2000, 2000], tableRows)
.Build();
}
@@ -180,10 +180,10 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
}
}
if (!result.Any())
{
throw new InvalidOperationException("No clients with deposits found");
}
//if (!result.Any())
//{
// throw new InvalidOperationException("No clients with deposits found");
//}
return result;
}

View File

@@ -28,7 +28,7 @@ public class OperationResponse
}
if (Result is Stream stream)
{
return new FileStreamResult(stream, "application/octetstream")
return new FileStreamResult(stream, "application/octet-stream")
{
FileDownloadName = FileName
};

View File

@@ -0,0 +1,74 @@
### <20><><EFBFBD><EFBFBD><EFBFBD> Word <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
GET /api/Report/LoadClientsByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, ClientSurname, ClientName, ClientBalance
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Word-<2D><><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
### <20><><EFBFBD><EFBFBD><EFBFBD> Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
GET /api/Report/LoadExcelClientByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, ClientSurname, ClientName, ClientBalance
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Excel-<2D><><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
### <20><><EFBFBD><EFBFBD><EFBFBD> PDF <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
GET /api/Report/LoadClientsByDeposit?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: ClientSurname, ClientName, ClientBalance, DepositRate, DepositPeriod, FromPeriod, ToPeriod
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> PDF-<2D><><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> currencyIds <20> GetDataClientsByDepositAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (JSON)
GET /api/Report/GetClientByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramId, CreditProgramName, ClientSurname, ClientName, ClientBalance
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (JSON)
GET /api/Report/GetClientByDeposit?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: ClientSurname, ClientName, ClientBalance, DepositRate, DepositPeriod, FromPeriod, ToPeriod
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> currencyIds <20> GetDataClientsByDepositAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Word <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
POST /api/Report/SendReportByCreditProgram HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}",
"creditProgramIds": {{creditProgramIds}}
}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, ClientSurname, ClientName, ClientBalance
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Word-<2D><><EFBFBD><EFBFBD><EFBFBD> <20><> email.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
POST /api/Report/SendExcelReportByCreditProgram HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}",
"creditProgramIds": {{creditProgramIds}}
}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, ClientSurname, ClientName, ClientBalance
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Excel-<2D><><EFBFBD><EFBFBD><EFBFBD> <20><> email.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> PDF <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
POST /api/Report/SendReportByDeposit?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}"
}
# <20><><EFBFBD><EFBFBD>: ClientSurname, ClientName, ClientBalance, DepositRate, DepositPeriod, FromPeriod, ToPeriod
# <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> PDF-<2D><><EFBFBD><EFBFBD><EFBFBD> <20><> email. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> currencyIds <20> GetDataClientsByDepositAsync.

View File

@@ -50,8 +50,8 @@ export const reportsApi = {
// Word отчеты
getWordReport: async (creditProgramIds: string[]) => {
const idsParam = creditProgramIds.reduce((prev, curr) => {
return (prev += `&creditProgramIds=${curr}`);
const idsParam = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
console.log('idsParam', idsParam);
const res = await fetch(
@@ -91,11 +91,11 @@ export const reportsApi = {
// Excel отчеты
getExcelReport: async (creditProgramIds: string[]) => {
const idsParam = creditProgramIds.reduce((prev, curr) => {
return (prev += `&creditProgramIds=${curr}`);
const idsParam = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
const res = await fetch(
`${API_URL}/api/Report/LoadExcelDepositByCreditProgram?creditProgramIds=${idsParam}`,
`${API_URL}/api/Report/LoadExcelDepositByCreditProgram?${idsParam}`,
{
credentials: 'include',
headers: {

View File

@@ -4,6 +4,8 @@ import {
postData,
postLoginData,
putData,
getFileData,
postEmailData,
} from './client';
import type {
ClientBindingModel,
@@ -133,3 +135,52 @@ export const storekeepersApi = {
getCurrentUser: () =>
getSingleData<StorekeeperBindingModel>('api/storekeepers/me'),
};
// Reports API
export const reportsApi = {
// PDF отчеты по депозитам
getDepositsPdfReport: (fromDate: string, toDate: string) =>
getFileData(
`api/Report/LoadClientsByDeposit?fromDate=${fromDate}&toDate=${toDate}`,
),
getDepositsDataReport: (fromDate: string, toDate: string) =>
getData(
`api/Report/GetClientByDeposit?fromDate=${fromDate}&toDate=${toDate}`,
),
sendDepositsPdfReport: (fromDate: string, toDate: string, email: string) =>
postEmailData(
`api/Report/SendReportByDeposit?fromDate=${fromDate}&toDate=${toDate}`,
{ email },
),
// Word отчеты по кредитным программам
getCreditProgramsWordReport: (creditProgramIds: string) =>
getFileData(`api/Report/LoadClientsByCreditProgram?${creditProgramIds}`),
getCreditProgramsDataReport: (creditProgramIds: string[]) =>
getData(
`api/Report/GetClientByCreditProgram?creditProgramIds=${creditProgramIds.join(
',',
)}`,
),
sendCreditProgramsWordReport: (creditProgramIds: string[], email: string) =>
postEmailData('api/Report/SendReportByCreditProgram', {
email,
creditProgramIds,
}),
// Excel отчеты по кредитным программам
getCreditProgramsExcelReport: (creditProgramIds: string) =>
getFileData(
`api/Report/LoadExcelClientByCreditProgram?${creditProgramIds}`,
),
sendCreditProgramsExcelReport: (creditProgramIds: string[], email: string) =>
postEmailData('api/Report/SendExcelReportByCreditProgram', {
email,
creditProgramIds,
}),
};

View File

@@ -69,3 +69,66 @@ export async function putData<T>(path: string, data: T) {
throw new Error(`Не получается загрузить ${path}: ${res.statusText}`);
}
}
// Функция для получения файлов отчетов
export async function getFileData(path: string): Promise<{
blob: Blob;
fileName: string;
mimeType: string;
}> {
const res = await fetch(`${API_URL}/${path}`, {
credentials: 'include',
// Убираем заголовок Content-Type для GET запросов файлов
});
if (!res.ok) {
throw new Error(`Не получается загрузить файл ${path}: ${res.statusText}`);
}
const blob = await res.blob();
const contentDisposition = res.headers.get('Content-Disposition');
const contentType =
res.headers.get('Content-Type') || 'application/octet-stream';
let fileName = 'report';
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
);
if (fileNameMatch) {
fileName = fileNameMatch[1].replace(/['"]/g, '');
}
}
// Если имя файла не извлечено из заголовка, пытаемся определить по URL
if (fileName === 'report') {
if (path.includes('LoadClientsByCreditProgram')) {
fileName = 'clientsbycreditprogram.docx';
} else if (path.includes('LoadExcelClientByCreditProgram')) {
fileName = 'clientsbycreditprogram.xlsx';
} else if (path.includes('LoadClientsByDeposit')) {
fileName = 'clientbydeposit.pdf';
}
}
return {
blob,
fileName,
mimeType: contentType,
};
}
// Функция для отправки email с отчетами
export async function postEmailData<T>(path: string, data: T) {
const res = await fetch(`${API_URL}/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error(`Не получается отправить email ${path}: ${res.statusText}`);
}
}

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { Button } from '../ui/button';
// Настройка worker для PDF.js
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.mjs`;
interface PdfViewerProps {
report: { blob: Blob; fileName: string; mimeType: string } | undefined | null;
}
export const PdfViewer = ({ report }: PdfViewerProps) => {
const [numPages, setNumPages] = React.useState<number | null>(null);
const [pageNumber, setPageNumber] = React.useState(1);
const [pdfUrl, setPdfUrl] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (report?.blob) {
const url = URL.createObjectURL(report.blob);
setPdfUrl(url);
setError(null);
return () => {
URL.revokeObjectURL(url);
};
} else {
setPdfUrl(null);
setNumPages(null);
setPageNumber(1);
}
}, [report]);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setPageNumber(1);
setError(null);
};
const onDocumentLoadError = (error: Error) => {
console.error('Ошибка загрузки PDF:', error);
setError(
'Ошибка при загрузке PDF документа. Пожалуйста, попробуйте снова.',
);
};
const handlePrevPage = () => {
setPageNumber((prev) => Math.max(prev - 1, 1));
};
const handleNextPage = () => {
setPageNumber((prev) => Math.min(prev + 1, numPages || 1));
};
if (!pdfUrl) {
return (
<div className="p-4 text-center">
{report
? 'Подготовка PDF для отображения...'
: 'Нет данных для отображения PDF.'}
</div>
);
}
return (
<div className="p-4">
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={<div className="text-center py-4">Загрузка PDF...</div>}
error={
<div className="text-center text-red-500 py-4">
Не удалось загрузить PDF
</div>
}
>
<Page
pageNumber={pageNumber}
renderTextLayer={false}
renderAnnotationLayer={false}
scale={1.2}
loading={<div className="text-center py-2">Загрузка страницы...</div>}
error={
<div className="text-center text-red-500 py-2">
Ошибка загрузки страницы
</div>
}
/>
</Document>
{error ? (
<div className="text-red-500 py-2 text-center">{error}</div>
) : numPages ? (
<div className="flex justify-between items-center mt-4">
<Button onClick={handlePrevPage} disabled={pageNumber <= 1}>
Предыдущая
</Button>
<p className="text-sm text-muted-foreground">
Страница {pageNumber} из {numPages}
</p>
<Button onClick={handleNextPage} disabled={pageNumber >= numPages}>
Следующая
</Button>
</div>
) : (
<div className="text-center py-2 text-muted-foreground">
Загрузка документа...
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
export type ReportCategory = 'deposits' | 'creditPrograms';
interface ReportSidebarProps {
selectedCategory: ReportCategory | null;
onCategorySelect: (category: ReportCategory) => void;
onReset: () => void;
}
export const ReportSidebar = ({
selectedCategory,
onCategorySelect,
onReset,
}: ReportSidebarProps) => {
return (
<div className="w-64 border-r bg-muted/10 p-4">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-3">Категории отчетов</h3>
<div className="space-y-2">
<Button
variant={selectedCategory === 'deposits' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => onCategorySelect('deposits')}
>
Отчеты по депозитам
</Button>
<Button
variant={
selectedCategory === 'creditPrograms' ? 'default' : 'outline'
}
className="w-full justify-start"
onClick={() => onCategorySelect('creditPrograms')}
>
Отчеты по кредитным программам
</Button>
</div>
</div>
<Separator />
<Button
variant="secondary"
className="w-full"
onClick={onReset}
disabled={!selectedCategory}
>
Сбросить выбор
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,481 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import {
CalendarIcon,
FileText,
Download,
Mail,
FileSpreadsheet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { PdfViewer } from './PdfViewer';
import { useCreditPrograms } from '@/hooks/useCreditPrograms';
import type { ReportCategory } from './ReportSidebar';
// Схемы валидации
const depositsReportSchema = z
.object({
fromDate: z.date({ required_error: 'Выберите дату начала' }),
toDate: z.date({ required_error: 'Выберите дату окончания' }),
})
.refine((data) => data.fromDate <= data.toDate, {
message: 'Дата начала должна быть раньше даты окончания',
path: ['toDate'],
});
const creditProgramsReportSchema = z.object({
creditProgramIds: z
.array(z.string())
.min(1, 'Выберите хотя бы одну кредитную программу'),
format: z.enum(['word', 'excel'], {
required_error: 'Выберите формат отчета',
}),
});
type DepositsReportForm = z.infer<typeof depositsReportSchema>;
type CreditProgramsReportForm = z.infer<typeof creditProgramsReportSchema>;
interface ReportViewerProps {
category: ReportCategory | null;
onGenerateReport: (type: string, data: Record<string, unknown>) => void;
onDownloadReport: (type: string, data: Record<string, unknown>) => void;
onSendEmail: (
type: string,
data: Record<string, unknown>,
email: string,
) => void;
pdfReport: { blob: Blob; fileName: string; mimeType: string } | null;
isLoading: boolean;
}
export const ReportViewer = ({
category,
onGenerateReport,
onDownloadReport,
onSendEmail,
pdfReport,
isLoading,
}: ReportViewerProps) => {
const { creditPrograms } = useCreditPrograms();
// Формы для разных типов отчетов
const depositsForm = useForm<DepositsReportForm>({
resolver: zodResolver(depositsReportSchema),
});
const creditProgramsForm = useForm<CreditProgramsReportForm>({
resolver: zodResolver(creditProgramsReportSchema),
defaultValues: {
creditProgramIds: [],
format: 'word',
},
});
// Обработчики для отчетов по депозитам
const handleGenerateDepositsReport = (data: DepositsReportForm) => {
onGenerateReport('deposits-pdf', {
fromDate: format(data.fromDate, 'yyyy-MM-dd'),
toDate: format(data.toDate, 'yyyy-MM-dd'),
});
};
const handleDownloadDepositsReport = (data: DepositsReportForm) => {
onDownloadReport('deposits-pdf', {
fromDate: format(data.fromDate, 'yyyy-MM-dd'),
toDate: format(data.toDate, 'yyyy-MM-dd'),
});
};
const handleSendDepositsEmail = (data: DepositsReportForm, email: string) => {
onSendEmail(
'deposits-pdf',
{
fromDate: format(data.fromDate, 'yyyy-MM-dd'),
toDate: format(data.toDate, 'yyyy-MM-dd'),
},
email,
);
};
// Обработчики для отчетов по кредитным программам
const handleDownloadCreditProgramsReport = (
data: CreditProgramsReportForm,
) => {
onDownloadReport(`creditPrograms-${data.format}`, {
creditProgramIds: data.creditProgramIds,
});
};
const handleSendCreditProgramsEmail = (
data: CreditProgramsReportForm,
email: string,
) => {
onSendEmail(
`creditPrograms-${data.format}`,
{
creditProgramIds: data.creditProgramIds,
},
email,
);
};
// Проверка валидности форм
const depositsFormData = depositsForm.watch();
const isDepositsFormValid =
depositsFormData.fromDate && depositsFormData.toDate;
const creditProgramsFormData = creditProgramsForm.watch();
const isCreditProgramsFormValid =
creditProgramsFormData.creditProgramIds?.length > 0;
// Обработка мультиселекта кредитных программ
const selectedCreditProgramIds =
creditProgramsForm.watch('creditProgramIds') || [];
const handleCreditProgramSelect = (creditProgramId: string) => {
const currentValues = selectedCreditProgramIds;
if (!currentValues.includes(creditProgramId)) {
creditProgramsForm.setValue('creditProgramIds', [
...currentValues,
creditProgramId,
]);
}
};
const handleCreditProgramRemove = (creditProgramId: string) => {
const newValues = selectedCreditProgramIds.filter(
(id) => id !== creditProgramId,
);
creditProgramsForm.setValue('creditProgramIds', newValues);
};
if (!category) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Выберите категорию отчета</h2>
<p className="text-muted-foreground">
Используйте боковую панель для выбора типа отчета
</p>
</div>
</div>
);
}
return (
<div className="flex-1 p-6">
{category === 'deposits' && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Отчеты по вкладам</h2>
<p className="text-muted-foreground">
PDF отчет с информацией о клиентах по вкладам за выбранный период
</p>
</div>
<Form {...depositsForm}>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={depositsForm.control}
name="fromDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Дата начала</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP', { locale: ru })
) : (
<span>Выберите дату</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date('1900-01-01')
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={depositsForm.control}
name="toDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Дата окончания</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP', { locale: ru })
) : (
<span>Выберите дату</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date('1900-01-01')
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
onClick={depositsForm.handleSubmit(
handleGenerateDepositsReport,
)}
disabled={!isDepositsFormValid || isLoading}
className="flex items-center gap-2"
>
<FileText className="h-4 w-4" />
Сгенерировать на странице
</Button>
<Button
type="button"
variant="outline"
onClick={depositsForm.handleSubmit(
handleDownloadDepositsReport,
)}
disabled={!isDepositsFormValid || isLoading}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
Скачать
</Button>
<Button
type="button"
variant="outline"
onClick={depositsForm.handleSubmit((data) => {
const email = prompt('Введите email для отправки:');
if (email) {
handleSendDepositsEmail(data, email);
}
})}
disabled={!isDepositsFormValid || isLoading}
className="flex items-center gap-2"
>
<Mail className="h-4 w-4" />
Отправить на почту
</Button>
</div>
</form>
</Form>
{pdfReport && (
<div className="border rounded-lg">
<PdfViewer report={pdfReport} />
</div>
)}
</div>
)}
{category === 'creditPrograms' && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">
Отчеты по кредитным программам
</h2>
<p className="text-muted-foreground">
Word или Excel отчет с информацией о клиентах по выбранным
кредитным программам
</p>
</div>
<Form {...creditProgramsForm}>
<form className="space-y-4">
<FormField
control={creditProgramsForm.control}
name="format"
render={({ field }) => (
<FormItem>
<FormLabel>Формат отчета</FormLabel>
<div className="flex gap-4">
<Button
type="button"
variant={field.value === 'word' ? 'default' : 'outline'}
onClick={() => field.onChange('word')}
className="flex items-center gap-2"
>
<FileText className="h-4 w-4" />
Word
</Button>
<Button
type="button"
variant={
field.value === 'excel' ? 'default' : 'outline'
}
onClick={() => field.onChange('excel')}
className="flex items-center gap-2"
>
<FileSpreadsheet className="h-4 w-4" />
Excel
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={creditProgramsForm.control}
name="creditProgramIds"
render={() => (
<FormItem>
<FormLabel>Кредитные программы</FormLabel>
<Select onValueChange={handleCreditProgramSelect}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите кредитные программы" />
</SelectTrigger>
</FormControl>
<SelectContent>
{creditPrograms?.map((program) => (
<SelectItem
key={program.id}
value={program.id || ''}
className={cn(
selectedCreditProgramIds.includes(
program.id || '',
) && 'bg-muted',
)}
>
{program.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2 mt-2">
{selectedCreditProgramIds.map((programId) => {
const program = creditPrograms?.find(
(p) => p.id === programId,
);
return (
<div
key={programId}
className="bg-muted px-2 py-1 rounded-md flex items-center gap-1"
>
<span>{program?.name || programId}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 rounded-full"
onClick={() =>
handleCreditProgramRemove(programId)
}
>
×
</Button>
</div>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
onClick={creditProgramsForm.handleSubmit(
handleDownloadCreditProgramsReport,
)}
disabled={!isCreditProgramsFormValid || isLoading}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
Скачать
</Button>
<Button
type="button"
variant="outline"
onClick={creditProgramsForm.handleSubmit((data) => {
const email = prompt('Введите email для отправки:');
if (email) {
handleSendCreditProgramsEmail(data, email);
}
})}
disabled={!isCreditProgramsFormValid || isLoading}
className="flex items-center gap-2"
>
<Mail className="h-4 w-4" />
Отправить на почту
</Button>
</div>
</form>
</Form>
</div>
)}
</div>
);
};

View File

@@ -71,6 +71,16 @@ const navOptions = [
},
],
},
{
name: 'Отчеты',
options: [
{
id: 1,
name: 'Выгрузить отчеты',
link: '/reports',
},
],
},
];
export const Header = (): React.JSX.Element => {

View File

@@ -77,12 +77,10 @@ export const Clients = (): React.JSX.Element => {
return clients.map((client) => {
const clerk = clerks.find((c) => c.id === client.clerkId);
// Находим вклады клиента
const clientDeposits = deposits?.filter(() => {
// Учитывая, что мы удалили depositClients из модели, эта проверка будет всегда возвращать false
// Здесь нужно реализовать другой способ связи, или просто удалить эту функциональность
return false; // Больше не можем определить связь через deposit.depositClients
});
const clientDeposits =
deposits?.filter((deposit) =>
client.depositClients?.some((dc) => dc.depositId === deposit.id),
) || [];
// Находим кредитные программы клиента
const clientCreditPrograms = creditPrograms.filter((creditProgram) =>
@@ -91,12 +89,9 @@ export const Clients = (): React.JSX.Element => {
),
);
// Формируем строки с информацией о вкладах и кредитах
const depositsList =
clientDeposits.length > 0
? clientDeposits
.map((d) => `${d.interestRate}% (${d.period} мес.)`)
.join(', ')
clientDeposits && clientDeposits.length > 0
? clientDeposits.map((d) => `Вклад ${d.interestRate}%`).join(', ')
: 'Нет вкладов';
const creditProgramsList =

View File

@@ -0,0 +1,257 @@
import React from 'react';
import { toast } from 'sonner';
import {
ReportSidebar,
type ReportCategory,
} from '@/components/features/ReportSidebar';
import { ReportViewer } from '@/components/features/ReportViewer';
import { useReports } from '@/hooks/useReports';
export const Reports = (): React.JSX.Element => {
const [selectedCategory, setSelectedCategory] =
React.useState<ReportCategory | null>(null);
const [pdfReport, setPdfReport] = React.useState<{
blob: Blob;
fileName: string;
mimeType: string;
} | null>(null);
const {
generateDepositsPdfReport,
isGeneratingDepositsPdf,
sendDepositsPdfReport,
isSendingDepositsPdf,
generateCreditProgramsWordReport,
isGeneratingCreditProgramsWord,
sendCreditProgramsWordReport,
isSendingCreditProgramsWord,
generateCreditProgramsExcelReport,
isGeneratingCreditProgramsExcel,
sendCreditProgramsExcelReport,
isSendingCreditProgramsExcel,
} = useReports();
const isLoading =
isGeneratingDepositsPdf ||
isSendingDepositsPdf ||
isGeneratingCreditProgramsWord ||
isSendingCreditProgramsWord ||
isGeneratingCreditProgramsExcel ||
isSendingCreditProgramsExcel;
const handleCategorySelect = (category: ReportCategory) => {
setSelectedCategory(category);
setPdfReport(null); // Сбрасываем PDF при смене категории
};
const handleReset = () => {
setSelectedCategory(null);
setPdfReport(null);
};
const downloadFile = (blob: Blob, fileName: string, mimeType?: string) => {
// Просто используем имя файла как есть, если оно уже содержит расширение
let finalFileName = fileName;
// Проверяем, есть ли уже расширение в имени файла
const hasExtension = /\.(docx|xlsx|pdf|doc|xls)$/i.test(fileName);
if (!hasExtension) {
// Только если нет расширения, пытаемся его добавить
if (mimeType && mimeType !== 'application/octet-stream') {
const extension = getExtensionFromMimeType(mimeType);
if (extension) {
finalFileName = `${fileName}${extension}`;
}
} else {
// Fallback: определяем по имени файла
const extension = getExtensionFromFileName(fileName);
if (extension) {
finalFileName = `${fileName}${extension}`;
}
}
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = finalFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const getExtensionFromMimeType = (mimeType: string): string => {
switch (mimeType.toLowerCase()) {
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
case 'application/msword':
return '.doc';
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return '.xlsx';
case 'application/vnd.ms-excel':
return '.xls';
case 'application/pdf':
return '.pdf';
default:
return '';
}
};
const getExtensionFromFileName = (fileName: string): string => {
const lowerFileName = fileName.toLowerCase();
if (lowerFileName.includes('clientsbycreditprogram')) {
return '.docx'; // Word файл для клиентов по кредитным программам
}
if (lowerFileName.includes('excel') || lowerFileName.includes('.xlsx')) {
return '.xlsx';
}
if (lowerFileName.includes('pdf') || lowerFileName.includes('deposit')) {
return '.pdf';
}
return '';
};
const handleGenerateReport = (
type: string,
data: Record<string, unknown>,
) => {
if (type === 'deposits-pdf') {
const { fromDate, toDate } = data as { fromDate: string; toDate: string };
generateDepositsPdfReport(
{ fromDate, toDate },
{
onSuccess: (report) => {
setPdfReport(report);
toast.success('PDF отчет успешно сгенерирован');
},
onError: (error) => {
console.error('Ошибка генерации PDF отчета:', error);
toast.error('Ошибка при генерации PDF отчета');
},
},
);
}
};
const handleDownloadReport = (
type: string,
data: Record<string, unknown>,
) => {
if (type === 'deposits-pdf') {
const { fromDate, toDate } = data as { fromDate: string; toDate: string };
generateDepositsPdfReport(
{ fromDate, toDate },
{
onSuccess: (report) => {
downloadFile(report.blob, report.fileName, report.mimeType);
toast.success('PDF отчет успешно скачан');
},
onError: (error) => {
console.error('Ошибка скачивания PDF отчета:', error);
toast.error('Ошибка при скачивании PDF отчета');
},
},
);
} else if (type === 'creditPrograms-word') {
const { creditProgramIds } = data as { creditProgramIds: string[] };
generateCreditProgramsWordReport(
{ creditProgramIds },
{
onSuccess: (report) => {
downloadFile(report.blob, report.fileName, report.mimeType);
toast.success('Word отчет успешно скачан');
},
onError: (error) => {
console.error('Ошибка скачивания Word отчета:', error);
toast.error('Ошибка при скачивании Word отчета');
},
},
);
} else if (type === 'creditPrograms-excel') {
const { creditProgramIds } = data as { creditProgramIds: string[] };
generateCreditProgramsExcelReport(
{ creditProgramIds },
{
onSuccess: (report) => {
downloadFile(report.blob, report.fileName, report.mimeType);
toast.success('Excel отчет успешно скачан');
},
onError: (error) => {
console.error('Ошибка скачивания Excel отчета:', error);
toast.error('Ошибка при скачивании Excel отчета');
},
},
);
}
};
const handleSendEmail = (
type: string,
data: Record<string, unknown>,
email: string,
) => {
if (type === 'deposits-pdf') {
const { fromDate, toDate } = data as { fromDate: string; toDate: string };
sendDepositsPdfReport(
{ fromDate, toDate, email },
{
onSuccess: () => {
toast.success(`PDF отчет успешно отправлен на ${email}`);
},
onError: (error) => {
console.error('Ошибка отправки PDF отчета:', error);
toast.error('Ошибка при отправке PDF отчета на email');
},
},
);
} else if (type === 'creditPrograms-word') {
const { creditProgramIds } = data as { creditProgramIds: string[] };
sendCreditProgramsWordReport(
{ creditProgramIds, email },
{
onSuccess: () => {
toast.success(`Word отчет успешно отправлен на ${email}`);
},
onError: (error) => {
console.error('Ошибка отправки Word отчета:', error);
toast.error('Ошибка при отправке Word отчета на email');
},
},
);
} else if (type === 'creditPrograms-excel') {
const { creditProgramIds } = data as { creditProgramIds: string[] };
sendCreditProgramsExcelReport(
{ creditProgramIds, email },
{
onSuccess: () => {
toast.success(`Excel отчет успешно отправлен на ${email}`);
},
onError: (error) => {
console.error('Ошибка отправки Excel отчета:', error);
toast.error('Ошибка при отправке Excel отчета на email');
},
},
);
}
};
return (
<main className="flex-1 flex relative">
<ReportSidebar
selectedCategory={selectedCategory}
onCategorySelect={handleCategorySelect}
onReset={handleReset}
/>
<ReportViewer
category={selectedCategory}
onGenerateReport={handleGenerateReport}
onDownloadReport={handleDownloadReport}
onSendEmail={handleSendEmail}
pdfReport={pdfReport}
isLoading={isLoading}
/>
</main>
);
};

View File

@@ -0,0 +1,121 @@
import { useMutation } from '@tanstack/react-query';
import { reportsApi } from '@/api/api';
export const useReports = () => {
// PDF отчеты по депозитам
const {
mutate: generateDepositsPdfReport,
isPending: isGeneratingDepositsPdf,
} = useMutation({
mutationFn: ({ fromDate, toDate }: { fromDate: string; toDate: string }) =>
reportsApi.getDepositsPdfReport(fromDate, toDate),
});
const { mutate: getDepositsData, isPending: isLoadingDepositsData } =
useMutation({
mutationFn: ({
fromDate,
toDate,
}: {
fromDate: string;
toDate: string;
}) => reportsApi.getDepositsDataReport(fromDate, toDate),
});
const { mutate: sendDepositsPdfReport, isPending: isSendingDepositsPdf } =
useMutation({
mutationFn: ({
fromDate,
toDate,
email,
}: {
fromDate: string;
toDate: string;
email: string;
}) => reportsApi.sendDepositsPdfReport(fromDate, toDate, email),
});
// Word отчеты по кредитным программам
const {
mutate: generateCreditProgramsWordReport,
isPending: isGeneratingCreditProgramsWord,
} = useMutation({
mutationFn: ({ creditProgramIds }: { creditProgramIds: string[] }) => {
const cpIds = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
return reportsApi.getCreditProgramsWordReport(cpIds); // не при каких обстоятельствах не менять
},
});
const {
mutate: getCreditProgramsData,
isPending: isLoadingCreditProgramsData,
} = useMutation({
mutationFn: ({ creditProgramIds }: { creditProgramIds: string[] }) =>
reportsApi.getCreditProgramsDataReport(creditProgramIds),
});
const {
mutate: sendCreditProgramsWordReport,
isPending: isSendingCreditProgramsWord,
} = useMutation({
mutationFn: ({
creditProgramIds,
email,
}: {
creditProgramIds: string[];
email: string;
}) => reportsApi.sendCreditProgramsWordReport(creditProgramIds, email),
});
// Excel отчеты по кредитным программам
const {
mutate: generateCreditProgramsExcelReport,
isPending: isGeneratingCreditProgramsExcel,
} = useMutation({
mutationFn: ({ creditProgramIds }: { creditProgramIds: string[] }) => {
const cpIds = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
return reportsApi.getCreditProgramsExcelReport(cpIds);
},
});
const {
mutate: sendCreditProgramsExcelReport,
isPending: isSendingCreditProgramsExcel,
} = useMutation({
mutationFn: ({
creditProgramIds,
email,
}: {
creditProgramIds: string[];
email: string;
}) => reportsApi.sendCreditProgramsExcelReport(creditProgramIds, email),
});
return {
// PDF отчеты по депозитам
generateDepositsPdfReport,
isGeneratingDepositsPdf,
getDepositsData,
isLoadingDepositsData,
sendDepositsPdfReport,
isSendingDepositsPdf,
// Word отчеты по кредитным программам
generateCreditProgramsWordReport,
isGeneratingCreditProgramsWord,
getCreditProgramsData,
isLoadingCreditProgramsData,
sendCreditProgramsWordReport,
isSendingCreditProgramsWord,
// Excel отчеты по кредитным программам
generateCreditProgramsExcelReport,
isGeneratingCreditProgramsExcel,
sendCreditProgramsExcelReport,
isSendingCreditProgramsExcel,
};
};

View File

@@ -15,6 +15,7 @@ import { Clerks } from './components/pages/Clerks.tsx';
import { Clients } from './components/pages/Clients.tsx';
import { Deposits } from './components/pages/Deposits.tsx';
import { Replenishments } from './components/pages/Replenishments.tsx';
import { Reports } from './components/pages/Reports.tsx';
const routes = createBrowserRouter([
{
@@ -41,6 +42,10 @@ const routes = createBrowserRouter([
path: '/replenishments',
element: <Replenishments />,
},
{
path: '/reports',
element: <Reports />,
},
],
errorElement: <p>бля пизда рулям</p>,
},