feat: простенький экран отчетов

This commit is contained in:
2025-05-21 21:16:49 +04:00
parent b977e76302
commit 92d02d4ba6
18 changed files with 484 additions and 17 deletions

View File

@@ -10,7 +10,7 @@ internal class BaseStorageContractTest
[OneTimeSetUp]
public void OneTimeSetUp()
{
BankDbContext = new BankDbContext(new ConfigurationDatabase());
BankDbContext = new BankDbContext(new Infrastructure.ConfigurationDatabase());
BankDbContext.Database.EnsureDeleted();
BankDbContext.Database.EnsureCreated();

View File

@@ -32,7 +32,7 @@ public class ClientAdapter : IClientAdapter
// Mapping for Deposit
cfg.CreateMap<DepositDataModel, DepositViewModel>()
.ForMember(dest => dest.DepositClients, opt => opt.MapFrom(src => src.Currencies)); // Adjust if Currencies is meant to map to DepositClients
.ForMember(dest => dest.DepositCurrencies, opt => opt.MapFrom(src => src.Currencies)); // Adjust if Currencies is meant to map to DepositClients
// Mapping for ClientCreditProgram
cfg.CreateMap<ClientCreditProgramBindingModel, ClientCreditProgramDataModel>();

View File

@@ -8,16 +8,10 @@ namespace BankWebApi.Controllers;
[Authorize]
[Route("api/[controller]/[action]")]
[ApiController]
public class ReportController : ControllerBase
public class ReportController(IReportAdapter adapter) : ControllerBase
{
private readonly IReportAdapter _adapter;
private readonly EmailService _emailService;
public ReportController(IReportAdapter adapter)
{
_adapter = adapter;
_emailService = EmailService.CreateYandexService();
}
private readonly IReportAdapter _adapter = adapter;
private readonly EmailService _emailService = EmailService.CreateYandexService();
[HttpGet]
[Consumes("application/json")]

Binary file not shown.

View File

@@ -32,6 +32,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "8.10.1",
"react-doc-viewer": "^0.1.14",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-router-dom": "^7.6.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -1,9 +1,10 @@
import { useAuthCheck } from '@/hooks/useAuthCheck';
import { useAuthStore } from '@/store/workerStore';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { Link, Navigate, Outlet, useLocation } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { Suspense } from 'react';
import { Button } from './components/ui/button';
function App() {
const user = useAuthStore((store) => store.user);
@@ -21,10 +22,27 @@ function App() {
return (
<>
<Header />
<Suspense fallback={<p>Loading...</p>}>
<Outlet />
</Suspense>
{location.pathname === '/' && (
<main className="flex justify-center items-center">
<div className="flex-1 flex justify-center items-center">
<img className="block" src="/Shrek.png" alt="кладовщик" />
</div>
<div className="flex-1">
<div>Удобный сервис для кладовщиков</div>
<Link to="/storekeepers">
<Button>За работу</Button>
</Link>
</div>
</main>
)}
{location.pathname !== '/' && (
<>
<Header />
<Suspense fallback={<p>Loading...</p>}>
<Outlet />
</Suspense>
</>
)}
<Footer />
</>
);

View File

@@ -1,5 +1,6 @@
import {
getData,
getReport,
getSingleData,
postData,
postLoginData,
@@ -129,3 +130,8 @@ export const storekeepersApi = {
getCurrentUser: () =>
getSingleData<StorekeeperBindingModel>('api/storekeepers/me'),
};
//Reports API
export const reportsApi = {
loadClientsByCreditProgram: () => getReport('path'),
};

View File

@@ -69,3 +69,135 @@ export async function putData<T>(path: string, data: T) {
throw new Error(`Не получается загрузить ${path}: ${res.statusText}`);
}
}
// report api
export interface ReportParams {
fromDate?: string; // Например, '2025-01-01'
toDate?: string; // Например, '2025-05-21'
}
export type ReportType =
| 'clientsByCreditProgram'
| 'clientsByDeposit'
| 'depositByCreditProgram'
| 'depositAndCreditProgramByCurrency';
export type ReportFormat = 'word' | 'excel' | 'pdf';
export async function getReport(
reportType: ReportType,
format: ReportFormat,
params?: ReportParams,
): Promise<{ blob: Blob; fileName: string; mimeType: string }> {
const actionMap: Record<ReportType, Record<ReportFormat, string>> = {
clientsByCreditProgram: {
word: 'LoadClientsByCreditProgram',
excel: 'LoadExcelClientByCreditProgram',
pdf: 'LoadPdfClientsByCreditProgram',
},
clientsByDeposit: {
word: 'LoadClientsByDeposit',
excel: 'LoadExcelClientsByDeposit',
pdf: 'LoadPdfClientsByDeposit',
},
depositByCreditProgram: {
word: 'LoadDepositByCreditProgram',
excel: 'LoadExcelDepositByCreditProgram',
pdf: 'LoadPdfDepositByCreditProgram',
},
depositAndCreditProgramByCurrency: {
word: 'LoadDepositAndCreditProgramByCurrency',
excel: 'LoadExcelDepositAndCreditProgramByCurrency',
pdf: 'LoadPdfDepositAndCreditProgramByCurrency',
},
};
const action = actionMap[reportType][format];
let query = '';
if (params) {
const paramParts: string[] = [];
if (params.fromDate)
paramParts.push(`fromDate=${encodeURIComponent(params.fromDate)}`);
if (params.toDate)
paramParts.push(`toDate=${encodeURIComponent(params.toDate)}`);
if (paramParts.length > 0) query = `?${paramParts.join('&')}`;
}
const url = `${API_URL}/api/Reports/${action}${query}`;
const res = await fetch(url, {
credentials: 'include',
});
if (!res.ok) {
throw new Error(
`Не удалось загрузить отчет ${reportType} (${format}): ${res.statusText}`,
);
}
const blob = await res.blob();
const contentDisposition = res.headers.get('Content-Disposition');
let fileName = `${reportType}.${format}`;
if (contentDisposition && contentDisposition.includes('filename=')) {
fileName = contentDisposition
.split('filename=')[1]
.replace(/"/g, '')
.trim();
}
const mimeType =
res.headers.get('Content-Type') ||
{
word: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
excel:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pdf: 'application/pdf',
}[format];
return { blob, fileName, mimeType };
}
export async function sendReportByEmail(
reportType: ReportType,
format: ReportFormat,
email: string,
params?: ReportParams,
): Promise<void> {
const actionMap: Record<ReportType, Record<ReportFormat, string>> = {
clientsByCreditProgram: {
word: 'SendReportByCreditProgram',
excel: 'SendExcelReportByCreditProgram',
pdf: 'SendPdfReportByCreditProgram',
},
clientsByDeposit: {
word: 'SendReportByDeposit',
excel: 'SendExcelReportByDeposit',
pdf: 'SendPdfReportByDeposit',
},
depositByCreditProgram: {
word: 'SendReportDepositByCreditProgram',
excel: 'SendExcelReportDepositByCreditProgram',
pdf: 'SendPdfReportDepositByCreditProgram',
},
depositAndCreditProgramByCurrency: {
word: 'SendReportByCurrency',
excel: 'SendExcelReportByCurrency',
pdf: 'SendPdfReportByCurrency',
},
};
const action = actionMap[reportType][format];
const res = await fetch(`${API_URL}/api/Reports/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, ...params }),
});
if (!res.ok) {
throw new Error(
`Не удалось отправить отчет ${reportType} (${format}): ${res.statusText}`,
);
}
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import DocViewer, { DocViewerRenderers } from 'react-doc-viewer';
import { useReports } from '@/hooks/useReports';
import { type ReportType, type ReportParams } from '@/api/client';
interface ExcelViewerProps {
reportType: ReportType;
params?: ReportParams;
}
export const ExcelViewer = ({ reportType, params }: ExcelViewerProps) => {
const { excelReport, isExcelLoading, isExcelError, excelError } = useReports(
reportType,
params,
);
const [documents, setDocuments] = React.useState<
{ uri: string; fileType: string }[]
>([]);
React.useEffect(() => {
if (excelReport?.blob) {
const uri = URL.createObjectURL(excelReport.blob);
setDocuments([{ uri, fileType: 'xlsx' }]);
return () => URL.revokeObjectURL(uri);
}
}, [excelReport]);
if (isExcelLoading) return <div className="p-4">Загрузка Excel...</div>;
if (isExcelError)
return (
<div className="p-4 text-red-500">Ошибка: {excelError?.message}</div>
);
return (
<div className="p-4">
{documents.length > 0 && (
<DocViewer
documents={documents}
pluginRenderers={DocViewerRenderers}
style={{ height: '500px' }}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import DocViewer, { DocViewerRenderers } from 'react-doc-viewer';
import { useReports } from '@/hooks/useReports';
import { type ReportType, type ReportParams } from '@/api/client';
interface PdfViewerProps {
reportType: ReportType;
params?: ReportParams;
}
export const PdfViewer = ({ reportType, params }: PdfViewerProps) => {
const { pdfReport, isPdfLoading, isPdfError, pdfError } = useReports(
reportType,
params,
);
const [documents, setDocuments] = React.useState<
{ uri: string; fileType: string }[]
>([]);
React.useEffect(() => {
if (pdfReport?.blob) {
const uri = URL.createObjectURL(pdfReport.blob);
setDocuments([{ uri, fileType: 'pdf' }]);
return () => URL.revokeObjectURL(uri);
}
}, [pdfReport]);
if (isPdfLoading) return <div className="p-4">Загрузка PDF...</div>;
if (isPdfError)
return <div className="p-4 text-red-500">Ошибка: {pdfError?.message}</div>;
return (
<div className="p-4">
{documents.length > 0 && (
<DocViewer
documents={documents}
pluginRenderers={DocViewerRenderers}
style={{ height: '500px' }}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import type { SelectedReport } from '../pages/Reports';
import { Button } from '../ui/button';
import { PdfViewer } from './PdfViewer';
import { WordViewer } from './WordViewer';
import { ExcelViewer } from './ExcelViewer';
type ReportViewerProps = {
selectedReport: SelectedReport;
};
export const ReportViewer = ({
selectedReport,
}: ReportViewerProps): React.JSX.Element => {
return (
<div className="w-full">
<div className="flex gap-10">
<Button>Сгенерировать</Button>
<Button>Сохранить</Button>
<Button>Отправить</Button>
</div>
<div>
{selectedReport === 'pdf' && (
<PdfViewer reportType={'clientsByCreditProgram'} />
)}
{selectedReport === 'word' && (
<WordViewer reportType={'clientsByCreditProgram'} />
)}
{selectedReport === 'excel' && (
<ExcelViewer reportType={'clientsByCreditProgram'} />
)}
{!selectedReport && <>не выбран отчет</>}
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import DocViewer, { DocViewerRenderers } from 'react-doc-viewer';
import { useReports } from '@/hooks/useReports';
import { type ReportType, type ReportParams } from '@/api/client';
interface WordViewerProps {
reportType: ReportType;
params?: ReportParams;
}
export const WordViewer = ({ reportType, params }: WordViewerProps) => {
const { wordReport, isWordLoading, isWordError, wordError } = useReports(
reportType,
params,
);
const [documents, setDocuments] = React.useState<
{ uri: string; fileType: string }[]
>([]);
React.useEffect(() => {
if (wordReport?.blob) {
const uri = URL.createObjectURL(wordReport.blob);
setDocuments([{ uri, fileType: 'docx' }]);
return () => URL.revokeObjectURL(uri);
}
}, [wordReport]);
if (isWordLoading) return <div className="p-4">Загрузка Word...</div>;
if (isWordError)
return <div className="p-4 text-red-500">Ошибка: {wordError?.message}</div>;
return (
<div className="p-4">
{documents.length > 0 && (
<DocViewer
documents={documents}
pluginRenderers={DocViewerRenderers}
style={{ height: '500px' }}
/>
)}
</div>
);
};

View File

@@ -18,7 +18,6 @@ import {
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Button } from '../ui/button';
import { useAuthStore } from '@/store/workerStore';
import { useStorekeepers } from '@/hooks/useStorekeepers';
type NavOptionValue = {
name: string;
@@ -72,6 +71,16 @@ const navOptions = [
},
],
},
{
name: 'Отчеты',
options: [
{
id: 1,
name: 'Выгрузить отчеты',
link: '/reports',
},
],
},
];
export const Header = (): React.JSX.Element => {

View File

@@ -0,0 +1,49 @@
import React from 'react';
import {
Sidebar,
SidebarContent,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
type SidebarProps = {
onWordClick: () => void;
onPdfClick: () => void;
onExcelClick: () => void;
};
export const ReportSidebar = ({
onWordClick,
onExcelClick,
onPdfClick,
}: SidebarProps): React.JSX.Element => {
return (
<SidebarProvider className="w-[400px]">
<Sidebar variant="floating" collapsible="none">
<SidebarContent />
<SidebarGroupContent className="">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onWordClick}>
<span>отчет word КЛАДОВЩИКА</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onExcelClick}>
<span>отчет excel КЛАДОВЩИКА</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onPdfClick}>
<span>отчет pdf КЛАДОВЩИКА</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</Sidebar>
</SidebarProvider>
);
};

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { ReportSidebar } from '../layout/ReportSidebar';
import { ReportViewer } from '../features/ReportViewer';
export type SelectedReport = 'word' | 'pdf' | 'excel' | undefined;
export const Reports = (): React.JSX.Element => {
const [selectedReport, setSelectedReport] = React.useState<SelectedReport>();
return (
<main className="flex">
<ReportSidebar
onWordClick={() => setSelectedReport('word')}
onPdfClick={() => setSelectedReport('pdf')}
onExcelClick={() => setSelectedReport('excel')}
/>
<ReportViewer selectedReport={selectedReport} />
</main>
);
};

View File

@@ -0,0 +1,66 @@
// reportsApi.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import {
getReport,
sendReportByEmail,
type ReportParams,
type ReportType,
type ReportFormat,
} from '@/api/client';
export const useReports = (reportType: ReportType, params?: ReportParams) => {
const requiresDates =
reportType === 'clientsByDeposit' ||
reportType === 'depositAndCreditProgramByCurrency';
const isEnabled: boolean =
Boolean(reportType) &&
(!requiresDates || (Boolean(params?.fromDate) && Boolean(params?.toDate)));
const pdfQuery = useQuery({
queryKey: ['pdf-document', reportType, params] as const,
queryFn: () => getReport(reportType, 'pdf', params),
enabled: isEnabled,
});
const wordQuery = useQuery({
queryKey: ['word-document', reportType, params] as const,
queryFn: () => getReport(reportType, 'word', params),
enabled: isEnabled,
});
const excelQuery = useQuery({
queryKey: ['excel-document', reportType, params] as const,
queryFn: () => getReport(reportType, 'excel', params),
enabled: isEnabled,
});
const sendReport = useMutation({
mutationFn: ({
reportType,
format,
email,
params,
}: {
reportType: ReportType;
format: ReportFormat;
email: string;
params?: ReportParams;
}) => sendReportByEmail(reportType, format, email, params),
});
return {
pdfReport: pdfQuery.data,
pdfError: pdfQuery.error,
isPdfError: pdfQuery.isError,
isPdfLoading: pdfQuery.isLoading,
wordReport: wordQuery.data,
wordError: wordQuery.error,
isWordError: wordQuery.isError,
isWordLoading: wordQuery.isLoading,
excelReport: excelQuery.data,
excelError: excelQuery.error,
isExcelError: excelQuery.isError,
isExcelLoading: excelQuery.isLoading,
sendReport,
};
};

View File

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