Merge pull request 'Task_7.5_UI_Reports' (#12) from Task_7.5_UI_Reports into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2025-05-27 22:25:08 +04:00
38 changed files with 3058 additions and 5398 deletions

View File

@@ -33,13 +33,16 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
public async Task<List<ClientsByCreditProgramDataModel>> GetDataClientsByCreditProgramAsync(List<string>? creditProgramIds, CancellationToken ct)
{
_logger.LogInformation("Get data ClientsByCreditProgram");
if (creditProgramIds is null || creditProgramIds.Count == 0)
{
return [];
}
var clients = await Task.Run(() => _clientStorage.GetList(), ct);
var creditPrograms = await Task.Run(() => _creditProgramStorage.GetList(), ct);
var currencies = await Task.Run(() => _currencyStorage.GetList(), ct);
var filteredPrograms = creditPrograms
.Where(cp => cp.Currencies.Any()) // Проверяем, что у кредитной программы есть связанные валюты
.Where(cp => creditProgramIds == null || creditProgramIds.Contains(cp.Id));
.Where(cp => creditProgramIds.Contains(cp.Id));
return filteredPrograms
.Select(cp => new ClientsByCreditProgramDataModel
@@ -76,20 +79,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 +183,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

@@ -0,0 +1,18 @@
namespace BankContracts.BindingModels;
public class ReportMailSendInfoBindingModel
{
public string Email { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}
public class CreditProgramReportMailSendInfoBindingModel : ReportMailSendInfoBindingModel
{
public List<string> CreditProgramIds { get; set; } = new();
}
public class DepositReportMailSendInfoBindingModel : ReportMailSendInfoBindingModel
{
// Для отчетов по депозитам дополнительные поля передаются через query параметры
}

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

@@ -88,8 +88,7 @@ public class ReportAdapter : IReportAdapter
{
try
{
return SendStream(await _reportContract.CreateDocumentClientsByDepositAsync(dateStart, dateFinish, ct),
"clientbydeposit.pdf");
return SendStream(await _reportContract.CreateDocumentClientsByDepositAsync(dateStart, dateFinish, ct), "clientbydeposit.pdf");
}
catch (IncorrectDatesException ex)
{

View File

@@ -1,5 +1,6 @@
using BankBusinessLogic.Implementations;
using BankContracts.AdapterContracts;
using BankContracts.BindingModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -8,16 +9,11 @@ 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;
private readonly IReportAdapter _adapter = adapter;
private readonly EmailService _emailService = EmailService.CreateYandexService();
public ReportController(IReportAdapter adapter)
{
_adapter = adapter;
_emailService = EmailService.CreateYandexService();
}
/// <summary>
/// Получение данных Клиента по Кредитным программам
/// </summary>
@@ -81,8 +77,7 @@ public class ReportController : ControllerBase
[Consumes("application/octet-stream")]
public async Task<IActionResult> LoadClientsByDeposit(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
{
return (await _adapter.CreateDocumentClientsByDepositAsync(fromDate,
toDate, cancellationToken)).GetResponse(Request, Response);
return (await _adapter.CreateDocumentClientsByDepositAsync(fromDate, toDate, cancellationToken)).GetResponse(Request, Response);
}
/// <summary>
@@ -126,6 +121,7 @@ public class ReportController : ControllerBase
/// <summary>
/// Получение данных Вклада и Кредитных программам по Валютам
/// кладовщик pdf
/// </summary>
/// <param name="fromDate"></param>
/// <param name="toDate"></param>
@@ -135,12 +131,12 @@ public class ReportController : ControllerBase
[Consumes("application/json")]
public async Task<IActionResult> GetDepositAndCreditProgramByCurrency(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
{
return (await _adapter.GetDataDepositAndCreditProgramByCurrencyAsync(fromDate, toDate,
cancellationToken)).GetResponse(Request, Response);
return (await _adapter.GetDataDepositAndCreditProgramByCurrencyAsync(fromDate, toDate, cancellationToken)).GetResponse(Request, Response);
}
/// <summary>
/// Отчет pdf Вклада и Кредитных программам по Валютам
/// кладовщик pdf
/// </summary>
/// <param name="fromDate"></param>
/// <param name="toDate"></param>
@@ -156,34 +152,36 @@ public class ReportController : ControllerBase
/// <summary>
/// Отправка word отчета Клиентов по Кредитным программам
/// </summary>
/// <param name="email"></param>
/// <param name="creditProgramIds"></param>
/// <param name="mailInfo"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendReportByCreditProgram(string email, [FromQuery] List<string>? creditProgramIds, CancellationToken ct)
public async Task<IActionResult> SendReportByCreditProgram([FromBody] CreditProgramReportMailSendInfoBindingModel mailInfo, CancellationToken ct)
{
try
{
var report = await _adapter.CreateDocumentClientsByCreditProgramAsync(creditProgramIds, ct);
var report = await _adapter.CreateDocumentClientsByCreditProgramAsync(mailInfo.CreditProgramIds, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".docx");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Отчет по клиентам по кредитным программам",
body: "<h1>Отчет по клиентам по кредитным программам</h1><p>В приложении находится отчет по клиентам по кредитным программам.</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Отчет успешно отправлен на почту");
}
@@ -196,37 +194,40 @@ public class ReportController : ControllerBase
}
/// <summary>
/// Отправка pdf отчета Клиентов по Валютам
/// Отправка pdf отчета Клиентов по Депозитам
/// </summary>
/// <param name="email"></param>
/// <param name="mailInfo"></param>
/// <param name="fromDate"></param>
/// <param name="toDate"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendReportByDeposit(string email, DateTime fromDate, DateTime toDate, CancellationToken ct)
public async Task<IActionResult> SendReportByDeposit([FromBody] DepositReportMailSendInfoBindingModel mailInfo, DateTime fromDate, DateTime toDate, CancellationToken ct)
{
try
{
var report = await _adapter.CreateDocumentClientsByDepositAsync(fromDate, toDate, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".pdf");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Отчет по клиентам по вкладам",
body: $"<h1>Отчет по клиентам по вкладам</h1><p>Отчет за период с {fromDate:dd.MM.yyyy} по {toDate:dd.MM.yyyy}</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Отчет успешно отправлен на почту");
}
@@ -240,36 +241,40 @@ public class ReportController : ControllerBase
/// <summary>
/// Отправка pdf отчета Вкладов и Кредитных программ по Валютам
/// кладовщик pdf
/// </summary>
/// <param name="email"></param>
/// <param name="mailInfo"></param>
/// <param name="fromDate"></param>
/// <param name="toDate"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendReportByCurrency(string email, DateTime fromDate, DateTime toDate, CancellationToken ct)
public async Task<IActionResult> SendReportByCurrency([FromBody] DepositReportMailSendInfoBindingModel mailInfo, DateTime fromDate, DateTime toDate, CancellationToken ct)
{
try
{
var report = await _adapter.CreateDocumentDepositAndCreditProgramByCurrencyAsync(fromDate, toDate, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".pdf");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Отчет по вкладам и кредитным программам по валютам",
body: $"<h1>Отчет по вкладам и кредитным программам по валютам</h1><p>Отчет за период с {fromDate:dd.MM.yyyy} по {toDate:dd.MM.yyyy}</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Отчет успешно отправлен на почту");
}
@@ -282,36 +287,38 @@ public class ReportController : ControllerBase
}
/// <summary>
/// Отправка excel отчета Клиентов по Кредитных программ
/// Отправка excel отчета Клиентов по Кредитным программам
/// </summary>
/// <param name="email"></param>
/// <param name="creditProgramIds"></param>
/// <param name="mailInfo"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendExcelReportByCreditProgram(string email, [FromQuery] List<string>? creditProgramIds, CancellationToken ct)
public async Task<IActionResult> SendExcelReportByCreditProgram([FromBody] CreditProgramReportMailSendInfoBindingModel mailInfo, CancellationToken ct)
{
try
{
var report = await _adapter.CreateExcelDocumentClientsByCreditProgramAsync(creditProgramIds, ct);
var report = await _adapter.CreateExcelDocumentClientsByCreditProgramAsync(mailInfo.CreditProgramIds, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".xlsx");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Excel отчет по клиентам по кредитным программам",
body: "<h1>Excel отчет по клиентам по кредитным программам</h1><p>В приложении находится Excel отчет по клиентам по кредитным программам.</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Excel отчет успешно отправлен на почту");
}
@@ -326,34 +333,36 @@ public class ReportController : ControllerBase
/// <summary>
/// Отправка word отчета Вкладов по Кредитных программ
/// </summary>
/// <param name="email"></param>
/// <param name="creditProgramIds"></param>
/// <param name="mailInfo"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendReportDepositByCreditProgram(string email, [FromQuery] List<string>? creditProgramIds, CancellationToken ct)
public async Task<IActionResult> SendReportDepositByCreditProgram([FromBody] CreditProgramReportMailSendInfoBindingModel mailInfo, CancellationToken ct)
{
try
{
var report = await _adapter.CreateDocumentDepositByCreditProgramAsync(creditProgramIds, ct);
var report = await _adapter.CreateDocumentDepositByCreditProgramAsync(mailInfo.CreditProgramIds, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".docx");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Отчет по вкладам по кредитным программам",
body: "<h1>Отчет по вкладам по кредитным программам</h1><p>В приложении находится отчет по вкладам по кредитным программам.</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Отчет успешно отправлен на почту");
}
@@ -368,34 +377,36 @@ public class ReportController : ControllerBase
/// <summary>
/// Отправка excel отчета Вкладов по Кредитных программ
/// </summary>
/// <param name="email"></param>
/// <param name="creditProgramIds"></param>
/// <param name="mailInfo"></param>
/// <param name="ct"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SendExcelReportDepositByCreditProgram(string email, [FromQuery] List<string>? creditProgramIds, CancellationToken ct)
public async Task<IActionResult> SendExcelReportDepositByCreditProgram([FromBody] CreditProgramReportMailSendInfoBindingModel mailInfo, CancellationToken ct)
{
try
{
var report = await _adapter.CreateExcelDocumentDepositByCreditProgramAsync(creditProgramIds, ct);
var report = await _adapter.CreateExcelDocumentDepositByCreditProgramAsync(mailInfo.CreditProgramIds, ct);
var response = report.GetResponse(Request, Response);
if (response is FileStreamResult fileResult)
{
var tempPath = Path.GetTempFileName();
using (var fileStream = new FileStream(tempPath, FileMode.Create))
var tempPathWithExtension = Path.ChangeExtension(tempPath, ".xlsx");
using (var fileStream = new FileStream(tempPathWithExtension, FileMode.Create))
{
await fileResult.FileStream.CopyToAsync(fileStream);
}
await _emailService.SendReportAsync(
toEmail: email,
subject: "Excel отчет по вкладам по кредитным программам",
body: "<h1>Excel отчет по вкладам по кредитным программам</h1><p>В приложении находится Excel отчет по вкладам по кредитным программам.</p>",
attachmentPath: tempPath
toEmail: mailInfo.Email,
subject: mailInfo.Subject,
body: mailInfo.Body,
attachmentPath: tempPathWithExtension
);
System.IO.File.Delete(tempPath);
System.IO.File.Delete(tempPathWithExtension);
return Ok("Excel отчет успешно отправлен на почту");
}

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

@@ -0,0 +1,76 @@
```
### <20><><EFBFBD><EFBFBD><EFBFBD> Word <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><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/LoadDepositByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, DepositRate, DepositCost, DepositPeriod
# <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> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><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> GetDataDepositByCreditProgramAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD> Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><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/LoadExcelDepositByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, DepositRate, DepositCost, DepositPeriod
# <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> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><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> GetDataDepositByCreditProgramAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD> PDF <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
GET /api/Report/LoadDepositAndCreditProgramByCurrency?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/octet-stream
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CurrencyName, CreditProgramName, CreditProgramMaxCost, 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> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><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> GetDataDepositAndCreditProgramByCurrencyAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><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/GetDepositByCreditProgram?creditProgramIds={{creditProgramIds}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, DepositRate, DepositCost, DepositPeriod
# <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> GetDataDepositByCreditProgramAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (JSON)
GET /api/Report/GetDepositAndCreditProgramByCurrency?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
# <20><><EFBFBD><EFBFBD>: CurrencyName, CreditProgramName, CreditProgramMaxCost, 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> GetDataDepositAndCreditProgramByCurrencyAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Word <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><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/SendReportDepositByCreditProgram HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}",
"creditProgramIds": {{creditProgramIds}}
}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, DepositRate, DepositCost, DepositPeriod
# <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> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> currencyIds <20> GetDataDepositByCreditProgramAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Excel <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><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/SendExcelReportDepositByCreditProgram HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}",
"creditProgramIds": {{creditProgramIds}}
}
# <20><><EFBFBD><EFBFBD>: CreditProgramName, DepositRate, DepositCost, DepositPeriod
# <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> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> currencyIds <20> GetDataDepositByCreditProgramAsync.
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> PDF <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> email: <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
POST /api/Report/SendReportByCurrency?fromDate={{fromDate}}&toDate={{toDate}} HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "{{email}}"
}
# <20><><EFBFBD><EFBFBD>: CurrencyName, CreditProgramName, CreditProgramMaxCost, 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> GetDataDepositAndCreditProgramByCurrencyAsync.
```

View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
package-lock.json

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -13,20 +13,22 @@
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@tailwindcss/vite": "^4.1.7",
"@tanstack/react-query": "^5.76.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,162 @@
import { ConfigManager } from '@/lib/config';
import type {
CreditProgramReportMailSendInfoBindingModel,
DepositReportMailSendInfoBindingModel,
} from '@/types/types';
const API_URL = ConfigManager.loadUrl();
export const reportsApi = {
// PDF отчеты
getPdfReport: async (fromDate: string, toDate: string) => {
const res = await fetch(
`${API_URL}/api/Report/LoadDepositAndCreditProgramByCurrency?fromDate=${fromDate}&toDate=${toDate}`,
{
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
},
},
);
if (!res.ok) {
throw new Error(`Не удалось загрузить PDF отчет: ${res.statusText}`);
}
return res.blob();
},
sendPdfReportByEmail: async (
mailInfo: DepositReportMailSendInfoBindingModel,
fromDate: string,
toDate: string,
) => {
const res = await fetch(
`${API_URL}/api/Report/SendReportByCurrency?fromDate=${fromDate}&toDate=${toDate}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(mailInfo),
},
);
if (!res.ok) {
throw new Error(`Не удалось отправить PDF отчет: ${res.statusText}`);
}
},
// Word отчеты
getWordReport: async (creditProgramIds: string[]) => {
const idsParam = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
console.log('idsParam', idsParam);
const res = await fetch(
`${API_URL}/api/Report/LoadDepositByCreditProgram?creditProgramIds=${idsParam}`,
{
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
},
},
);
if (!res.ok) {
throw new Error(`Не удалось загрузить Word отчет: ${res.statusText}`);
}
return res.blob();
},
sendWordReportByEmail: async (
mailInfo: CreditProgramReportMailSendInfoBindingModel,
) => {
const res = await fetch(
`${API_URL}/api/Report/SendReportDepositByCreditProgram`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(mailInfo),
},
);
if (!res.ok) {
throw new Error(`Не удалось отправить Word отчет: ${res.statusText}`);
}
},
// Excel отчеты
getExcelReport: async (creditProgramIds: string[]) => {
const idsParam = creditProgramIds.reduce((prev, curr, index) => {
return (prev += `${index === 0 ? '' : '&'}creditProgramIds=${curr}`);
}, '');
const res = await fetch(
`${API_URL}/api/Report/LoadExcelDepositByCreditProgram?${idsParam}`,
{
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
},
},
);
if (!res.ok) {
throw new Error(`Не удалось загрузить Excel отчет: ${res.statusText}`);
}
return res.blob();
},
sendExcelReportByEmail: async (
mailInfo: CreditProgramReportMailSendInfoBindingModel,
) => {
const res = await fetch(
`${API_URL}/api/Report/SendExcelReportDepositByCreditProgram`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(mailInfo),
},
);
if (!res.ok) {
throw new Error(`Не удалось отправить Excel отчет: ${res.statusText}`);
}
},
// Получение данных для предпросмотра
getReportData: async (creditProgramIds: string[]) => {
const idsParam = creditProgramIds.join(',');
const res = await fetch(
`${API_URL}/api/Report/GetDepositByCreditProgram?creditProgramIds=${idsParam}`,
{
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
},
);
if (!res.ok) {
throw new Error(`Не удалось загрузить данные отчета: ${res.statusText}`);
}
return res.json();
},
getReportDataByCurrency: async (fromDate: string, toDate: string) => {
const res = await fetch(
`${API_URL}/api/Report/GetDepositAndCreditProgramByCurrency?fromDate=${fromDate}&toDate=${toDate}`,
{
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
},
);
if (!res.ok) {
throw new Error(
`Не удалось загрузить данные отчета по валюте: ${res.statusText}`,
);
}
return res.json();
},
};

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { Document, Page } from 'react-pdf';
import * as pdfjs from 'pdfjs-dist';
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
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
// Настройка 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;
@@ -15,25 +14,36 @@ interface PdfViewerProps {
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 | undefined>(undefined);
const [pdfUrl, setPdfUrl] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
// Создаем URL для Blob при изменении report
React.useEffect(() => {
if (report?.blob) {
const url = URL.createObjectURL(report.blob);
setPdfUrl(url);
setError(null);
// Очищаем URL при размонтировании компонента или изменении report
return () => URL.revokeObjectURL(url);
return () => {
URL.revokeObjectURL(url);
};
} else {
setPdfUrl(undefined);
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 = () => {
@@ -46,7 +56,11 @@ export const PdfViewer = ({ report }: PdfViewerProps) => {
if (!pdfUrl) {
return (
<div className="p-4">Загрузка или нет данных для отображения PDF.</div>
<div className="p-4 text-center">
{report
? 'Подготовка PDF для отображения...'
: 'Нет данных для отображения PDF.'}
</div>
);
}
@@ -55,12 +69,7 @@ export const PdfViewer = ({ report }: PdfViewerProps) => {
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) => {
console.error('Ошибка загрузки PDF:', error);
setError(
'Ошибка при загрузке PDF документа. Пожалуйста, попробуйте снова.',
);
}}
onLoadError={onDocumentLoadError}
loading={<div className="text-center py-4">Загрузка PDF...</div>}
error={
<div className="text-center text-red-500 py-4">
@@ -83,22 +92,23 @@ export const PdfViewer = ({ report }: PdfViewerProps) => {
</Document>
{error ? (
<div className="text-red-500 py-2">{error}</div>
) : (
<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>
Страница {pageNumber} из {numPages || 1}
<p className="text-sm text-muted-foreground">
Страница {pageNumber} из {numPages}
</p>
<Button
onClick={handleNextPage}
disabled={pageNumber >= (numPages || 1)}
>
<Button onClick={handleNextPage} disabled={pageNumber >= numPages}>
Следующая
</Button>
</div>
) : (
<div className="text-center py-2 text-muted-foreground">
Загрузка документа...
</div>
)}
</div>
);

View File

@@ -1,518 +1,342 @@
import React from 'react';
import type { SelectedReport } from '../pages/Reports';
import { Button } from '../ui/button';
import { PdfViewer } from './PdfViewer';
import { DialogForm } from '../layout/DialogForm';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { toast } from 'sonner';
import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { CalendarIcon, FileText, FileSpreadsheet } from 'lucide-react';
import { ru } from 'date-fns/locale';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { useCreditPrograms } from '@/hooks/useCreditPrograms';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import { ConfigManager } from '@/lib/config';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type ReportViewerProps = {
selectedReport: SelectedReport;
};
type ReportCategory = 'pdf' | 'word-excel' | null;
type FileFormat = 'doc' | 'xls';
type ReportData = { blob: Blob; fileName: string; mimeType: string };
const emailFormSchema = z.object({
toEmail: z.string().email({ message: 'Введите корректный email' }),
subject: z.string().min(1, { message: 'Тема обязательна' }),
body: z.string().min(1, { message: 'Текст сообщения обязателен' }),
});
const API_URL = ConfigManager.loadUrl();
interface ReportViewerProps {
selectedCategory: ReportCategory;
onGeneratePdf: (fromDate: Date, toDate: Date) => void;
onDownloadPdf: (fromDate: Date, toDate: Date) => void;
onSendPdfEmail: (fromDate: Date, toDate: Date) => void;
onDownloadWordExcel: (format: FileFormat, creditProgramIds: string[]) => void;
onSendWordExcelEmail: (
format: FileFormat,
creditProgramIds: string[],
) => void;
pdfReport?: { blob: Blob; fileName: string; mimeType: string } | null;
}
export const ReportViewer = ({
selectedReport,
selectedCategory,
onGeneratePdf,
onDownloadPdf,
onSendPdfEmail,
onDownloadWordExcel,
onSendWordExcelEmail,
pdfReport,
}: ReportViewerProps): React.JSX.Element => {
const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
const [fromDate, setFromDate] = React.useState<Date | undefined>(undefined);
const [toDate, setToDate] = React.useState<Date | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
const [report, setReport] = React.useState<ReportData | null>(null);
const { creditPrograms } = useCreditPrograms();
const form = useForm<z.infer<typeof emailFormSchema>>({
resolver: zodResolver(emailFormSchema),
defaultValues: {
toEmail: '',
subject: getDefaultSubject(selectedReport),
body: getDefaultBody(selectedReport, fromDate, toDate),
},
});
const [fromDate, setFromDate] = React.useState<Date>();
const [toDate, setToDate] = React.useState<Date>();
const [fileFormat, setFileFormat] = React.useState<FileFormat>('doc');
const [selectedCreditProgramIds, setSelectedCreditProgramIds] =
React.useState<string[]>([]);
React.useEffect(() => {
form.setValue('subject', getDefaultSubject(selectedReport));
form.setValue('body', getDefaultBody(selectedReport, fromDate, toDate));
const isPdfDatesValid = fromDate && toDate;
const isWordExcelDataValid = selectedCreditProgramIds.length > 0;
setReport(null);
}, [selectedReport, fromDate, toDate, form]);
const getReportTitle = (report: SelectedReport) => {
switch (report) {
case 'word':
return 'Отчет Word по вкладам по кредитным программам';
case 'excel':
return 'Отчет Excel по вкладам по кредитным программам';
case 'pdf':
return 'Отчет PDF по вкладам и кредитным программам по валютам';
default:
return 'Выберите тип отчета';
}
const handleCreditProgramSelect = (creditProgramId: string) => {
setSelectedCreditProgramIds((prev) => {
if (prev.includes(creditProgramId)) {
return prev.filter((id) => id !== creditProgramId);
} else {
return [...prev, creditProgramId];
}
});
};
function getDefaultSubject(report: SelectedReport | undefined): string {
switch (report) {
case 'word':
return 'Отчет по вкладам по кредитным программам';
case 'excel':
return 'Excel отчет по вкладам по кредитным программам';
case 'pdf':
return 'Отчет по вкладам и кредитным программам по валютам';
default:
return 'Отчет';
}
}
function getDefaultBody(
report: SelectedReport | undefined,
fromDate?: Date,
toDate?: Date,
): string {
switch (report) {
case 'word':
return 'В приложении находится отчет по вкладам по кредитным программам.';
case 'excel':
return 'В приложении находится Excel отчет по вкладам по кредитным программам.';
case 'pdf':
return `В приложении находится отчет по вкладам и кредитным программам по валютам${
fromDate && toDate
? ` за период с ${format(fromDate, 'dd.MM.yyyy')} по ${format(
toDate,
'dd.MM.yyyy',
)}`
: ''
}.`;
default:
return '';
}
}
const getReportUrl = (
selectedReport: SelectedReport,
fromDate?: Date,
toDate?: Date,
): string => {
switch (selectedReport) {
case 'word':
return `${API_URL}/api/Report/LoadDepositByCreditProgram`;
case 'excel':
return `${API_URL}/api/Report/LoadExcelDepositByCreditProgram`;
case 'pdf': {
if (!fromDate || !toDate) {
throw new Error('Необходимо выбрать даты для PDF отчета');
}
const fromDateStr = format(fromDate, 'yyyy-MM-dd');
const toDateStr = format(toDate, 'yyyy-MM-dd');
return `${API_URL}/api/Report/LoadDepositAndCreditProgramByCurrency?fromDate=${fromDateStr}&toDate=${toDateStr}`;
}
default:
throw new Error('Выберите тип отчета');
}
};
const getSendEmailUrl = (selectedReport: SelectedReport): string => {
switch (selectedReport) {
case 'word':
return `${API_URL}/api/Report/SendReportDepositByCreditProgram`;
case 'excel':
return `${API_URL}/api/Report/SendExcelReportDepositByCreditProgram`;
case 'pdf': {
if (!fromDate || !toDate) {
throw new Error('Необходимо выбрать даты для PDF отчета');
}
const fromDateStr = format(fromDate, 'yyyy-MM-dd');
const toDateStr = format(toDate, 'yyyy-MM-dd');
return `${API_URL}/api/Report/SendReportByCurrency?fromDate=${fromDateStr}&toDate=${toDateStr}`;
}
default:
throw new Error('Выберите тип отчета');
}
};
const fetchReport = async (): Promise<ReportData> => {
try {
setIsLoading(true);
setError(null);
const url = getReportUrl(selectedReport, fromDate, toDate);
console.log(`Загружаем отчет с URL: ${url}`);
const acceptHeader =
selectedReport === 'pdf'
? 'application/pdf'
: selectedReport === 'word'
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: acceptHeader,
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(
`Ошибка загрузки отчета: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
console.log(
`Отчет загружен. Тип: ${blob.type}, размер: ${blob.size} байт`,
);
const contentDisposition = response.headers.get('Content-Disposition');
const defaultExtension =
selectedReport === 'pdf'
? '.pdf'
: selectedReport === 'word'
? '.docx'
: '.xlsx';
let fileName = `report${defaultExtension}`;
if (contentDisposition && contentDisposition.includes('filename=')) {
fileName = contentDisposition
.split('filename=')[1]
.replace(/"/g, '')
.trim();
}
const mimeType = response.headers.get('Content-Type') || '';
const reportData = { blob, fileName, mimeType };
setReport(reportData);
return reportData;
} catch (error) {
console.error('Ошибка при загрузке отчета:', error);
const err =
error instanceof Error ? error : new Error('Неизвестная ошибка');
setError(err);
throw err;
} finally {
setIsLoading(false);
}
};
const handleGenerate = async () => {
try {
await fetchReport();
toast.success('Отчет успешно загружен');
} catch (error) {
toast.error(
`Ошибка загрузки отчета: ${
error instanceof Error ? error.message : 'Неизвестная ошибка'
}`,
);
}
};
const handleDownload = async () => {
try {
let reportData = report;
// Для PDF всегда делаем новый запрос с актуальными датами
if (selectedReport === 'pdf') {
if (!fromDate || !toDate) {
toast.error('Пожалуйста, выберите даты для PDF отчета');
return;
}
toast.loading('Загрузка отчета...');
reportData = await fetchReport();
} else if (!reportData) {
toast.loading('Загрузка отчета...');
reportData = await fetchReport();
}
// Скачиваем отчет
const url = URL.createObjectURL(reportData.blob);
const a = document.createElement('a');
a.href = url;
a.download = reportData.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Отчет успешно скачан');
} catch (error) {
toast.error(
`Ошибка при скачивании отчета: ${
error instanceof Error ? error.message : 'Неизвестная ошибка'
}`,
);
}
};
const handleSendFormSubmit = async (
values: z.infer<typeof emailFormSchema>,
) => {
try {
// Если выбран PDF отчет, проверяем наличие дат
if (selectedReport === 'pdf' && (!fromDate || !toDate)) {
toast.error('Пожалуйста, выберите даты для PDF отчета');
return;
}
setIsLoading(true);
// Формируем данные для отправки
const url = getSendEmailUrl(selectedReport);
// Параметры для запроса
const data: Record<string, string> = {
toEmail: values.toEmail,
subject: values.subject,
body: values.body,
};
// Отправляем запрос
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Ошибка при отправке отчета: ${response.status} ${response.statusText}\n${errorText}`,
);
}
toast.success('Отчет успешно отправлен на почту');
setIsSendDialogOpen(false);
form.reset();
} catch (error) {
console.error('Ошибка при отправке отчета:', error);
toast.error(
`Ошибка при отправке отчета: ${
error instanceof Error ? error.message : 'Неизвестная ошибка'
}`,
);
} finally {
setIsLoading(false);
}
};
// Проверка, можно ли сгенерировать/скачать/отправить PDF отчет
const isPdfActionDisabled =
selectedReport === 'pdf' && (!fromDate || !toDate || isLoading);
// Отображение ошибки, если она есть
const renderError = () => {
if (!error) return null;
return (
<div className="p-4 border border-red-300 bg-red-50 rounded-md mt-2">
<h3 className="text-red-700 font-semibold mb-1">Детали ошибки:</h3>
<p className="text-red-600 whitespace-pre-wrap break-words">
{error.message}
</p>
</div>
const removeCreditProgram = (creditProgramId: string) => {
setSelectedCreditProgramIds((prev) =>
prev.filter((id) => id !== creditProgramId),
);
};
return (
<div className="w-full">
<div className="text-lg font-semibold mb-4">
{getReportTitle(selectedReport)}
if (!selectedCategory) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Выберите тип отчета</h2>
<p className="text-muted-foreground">
Выберите категорию отчета в боковой панели для начала работы
</p>
</div>
</div>
);
}
{/* Кнопки действий */}
<div className="flex gap-4 mb-4">
{/* Кнопка "Сгенерировать" только для PDF с выбранными датами */}
{selectedReport === 'pdf' && (
<Button onClick={handleGenerate} disabled={isPdfActionDisabled}>
{isLoading ? 'Загрузка...' : 'Сгенерировать'}
</Button>
)}
if (selectedCategory === 'pdf') {
return (
<div className="flex-1 flex flex-col">
{/* Панель управления PDF */}
<div className="border-b p-4 bg-background">
<h2 className="text-xl font-semibold mb-4">
PDF Отчет по валютам и периодам
</h2>
{/* Кнопки "Скачать" и "Отправить" только когда выбран тип отчета */}
{selectedReport && (
<>
<Button
onClick={handleDownload}
disabled={isPdfActionDisabled || isLoading}
>
{isLoading ? 'Загрузка...' : 'Скачать'}
</Button>
{/* Выбор дат */}
<div className="flex gap-4 mb-4">
<div className="flex-1">
<Label>Дата начала</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!fromDate && 'text-muted-foreground',
)}
>
{fromDate ? (
format(fromDate, 'PPP', { locale: ru })
) : (
<span>Выберите дату</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={fromDate}
onSelect={setFromDate}
locale={ru}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Button
onClick={() => setIsSendDialogOpen(true)}
disabled={isPdfActionDisabled || isLoading}
>
Отправить
</Button>
</>
)}
</div>
{/* Календари для выбора периода для PDF отчета */}
{selectedReport === 'pdf' && (
<div className="flex gap-4 mb-4">
<div className="grid gap-2">
<Label htmlFor="fromDate">От даты</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={cn(
'w-[240px] justify-start text-left font-normal',
!fromDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{fromDate ? (
format(fromDate, 'PPP')
) : (
<span>Выберите дату</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={fromDate}
onSelect={setFromDate}
initialFocus
/>
</PopoverContent>
</Popover>
<div className="flex-1">
<Label>Дата окончания</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!toDate && 'text-muted-foreground',
)}
>
{toDate ? (
format(toDate, 'PPP', { locale: ru })
) : (
<span>Выберите дату</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={toDate}
onSelect={setToDate}
locale={ru}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="toDate">До даты</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={cn(
'w-[240px] justify-start text-left font-normal',
!toDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{toDate ? format(toDate, 'PPP') : <span>Выберите дату</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={toDate}
onSelect={setToDate}
initialFocus
/>
</PopoverContent>
</Popover>
{/* Кнопки действий */}
<div className="flex gap-2">
<Button
onClick={() =>
isPdfDatesValid && onGeneratePdf(fromDate!, toDate!)
}
disabled={!isPdfDatesValid}
>
Сгенерировать на странице
</Button>
<Button
onClick={() =>
isPdfDatesValid && onDownloadPdf(fromDate!, toDate!)
}
disabled={!isPdfDatesValid}
variant="outline"
>
Скачать
</Button>
<Button
onClick={() =>
isPdfDatesValid && onSendPdfEmail(fromDate!, toDate!)
}
disabled={!isPdfDatesValid}
variant="outline"
>
Отправить на почту
</Button>
</div>
</div>
)}
{/* Форма отправки отчета на почту */}
<DialogForm
title="Отправка отчета"
description="Введите данные для отправки отчета"
isOpen={isSendDialogOpen}
onClose={() => setIsSendDialogOpen(false)}
onSubmit={form.handleSubmit(handleSendFormSubmit)}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSendFormSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="toEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email получателя</FormLabel>
<FormControl>
<Input placeholder="example@mail.ru" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Тема письма</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>Текст сообщения</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Отправить</Button>
</form>
</Form>
</DialogForm>
<div className="mt-4">
{isLoading && <div className="p-4">Загрузка документа...</div>}
{renderError()}
{!selectedReport && !isLoading && !error && (
<div className="p-4">Выберите тип отчета из боковой панели</div>
)}
{selectedReport && !report && !isLoading && !error && (
<div className="p-4">
{selectedReport === 'pdf'
? 'Выберите даты и нажмите "Сгенерировать"'
: 'Нажмите "Скачать" для загрузки отчета'}
</div>
)}
{selectedReport === 'pdf' && report && <PdfViewer report={report} />}
{/* Область просмотра PDF */}
<div className="flex-1 overflow-auto">
{pdfReport ? (
<PdfViewer report={pdfReport} />
) : (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
Выберите даты и нажмите "Сгенерировать на странице" для
просмотра отчета
</p>
</div>
)}
</div>
</div>
</div>
);
);
}
if (selectedCategory === 'word-excel') {
return (
<div className="flex-1 p-6">
<h2 className="text-xl font-semibold mb-6">
Word/Excel Отчет по кредитным программам
</h2>
{/* Выбор формата файла */}
<div className="mb-6">
<Label className="text-base font-medium mb-3 block">
Формат файла
</Label>
<RadioGroup
value={fileFormat}
onValueChange={(value) => setFileFormat(value as FileFormat)}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="doc" id="doc" />
<Label
htmlFor="doc"
className="flex items-center gap-2 cursor-pointer"
>
<FileText className="h-4 w-4" />
Microsoft Word
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="xls" id="xls" />
<Label
htmlFor="xls"
className="flex items-center gap-2 cursor-pointer"
>
<FileSpreadsheet className="h-4 w-4" />
Microsoft Excel
</Label>
</div>
</RadioGroup>
</div>
{/* Выбор кредитных программ */}
<div className="mb-6">
<Label className="text-base font-medium mb-3 block">
Кредитные программы
</Label>
<Select
onValueChange={(value) => {
if (!selectedCreditProgramIds.includes(value)) {
handleCreditProgramSelect(value);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Выберите кредитные программы" />
</SelectTrigger>
<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-3">
{selectedCreditProgramIds.map((programId) => {
const program = creditPrograms?.find((p) => p.id === programId);
return (
<div
key={programId}
className="bg-muted px-3 py-1 rounded-md flex items-center gap-2"
>
<span>{program?.name || programId}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 rounded-full"
onClick={() => removeCreditProgram(programId)}
>
×
</Button>
</div>
);
})}
</div>
</div>
{/* Кнопки действий */}
<div className="flex gap-2">
<Button
onClick={() =>
isWordExcelDataValid &&
onDownloadWordExcel(fileFormat, selectedCreditProgramIds)
}
disabled={!isWordExcelDataValid}
>
Скачать
</Button>
<Button
onClick={() =>
isWordExcelDataValid &&
onSendWordExcelEmail(fileFormat, selectedCreditProgramIds)
}
disabled={!isWordExcelDataValid}
variant="outline"
>
Отправить на почту
</Button>
</div>
{!isWordExcelDataValid && (
<p className="text-sm text-muted-foreground mt-4">
Выберите хотя бы одну кредитную программу для активации кнопок
</p>
)}
</div>
);
}
return <div></div>;
};

View File

@@ -1,58 +1,64 @@
import React from 'react';
import {
Sidebar,
SidebarContent,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { FileText, FileSpreadsheet } from 'lucide-react';
type ReportCategory = 'pdf' | 'word-excel' | null;
interface ReportSidebarProps {
selectedCategory: ReportCategory;
onCategoryChange: (category: ReportCategory) => void;
}
type SidebarProps = {
onWordClick: () => void;
onPdfClick: () => void;
onExcelClick: () => void;
};
export const ReportSidebar = ({
onWordClick,
onExcelClick,
onPdfClick,
}: SidebarProps): React.JSX.Element => {
selectedCategory,
onCategoryChange,
}: ReportSidebarProps): React.JSX.Element => {
return (
<SidebarProvider className="w-[400px]">
<Sidebar variant="floating" collapsible="none">
<SidebarContent />
<SidebarGroupContent className="">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onWordClick}>
<span>
<img src="/icons/word.svg" alt="word-icon" />
отчет word КЛАДОВЩИКА
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onExcelClick}>
<span>
<img src="/icons/excel.svg" alt="excel-icon" />
отчет excel КЛАДОВЩИКА
</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild onClick={onPdfClick}>
<span className="p-5">
<img src="/icons/pdf.svg" alt="pdf-icon" />
отчет pdf КЛАДОВЩИКА
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</Sidebar>
</SidebarProvider>
<div className="w-80 border-r bg-background">
<div className="space-y-4 p-4">
<div>
<h3 className="mb-4 text-lg font-medium">Категории отчетов</h3>
<RadioGroup
value={selectedCategory || ''}
onValueChange={(value) => onCategoryChange(value as ReportCategory)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pdf" id="pdf" />
<Label
htmlFor="pdf"
className="flex items-center gap-2 cursor-pointer"
>
<FileText className="h-4 w-4" />
PDF отчет по валютам и периодам
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="word-excel" id="word-excel" />
<Label
htmlFor="word-excel"
className="flex items-center gap-2 cursor-pointer"
>
<FileSpreadsheet className="h-4 w-4" />
Word/Excel отчет по кредитным программам
</Label>
</div>
</RadioGroup>
</div>
{selectedCategory && (
<div className="pt-4 border-t">
<Button
variant="outline"
onClick={() => onCategoryChange(null)}
className="w-full"
>
Сбросить выбор
</Button>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,20 +1,237 @@
import React from 'react';
import { ReportSidebar } from '../layout/ReportSidebar';
import { ReportViewer } from '../features/ReportViewer';
import { reportsApi } from '@/api/reports';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { EmailDialog } from '@/components/ui/EmailDialog';
import type {
CreditProgramReportMailSendInfoBindingModel,
DepositReportMailSendInfoBindingModel,
} from '@/types/types';
export type SelectedReport = 'word' | 'pdf' | 'excel' | undefined;
type ReportCategory = 'pdf' | 'word-excel' | null;
type FileFormat = 'doc' | 'xls';
export const Reports = (): React.JSX.Element => {
const [selectedReport, setSelectedReport] = React.useState<SelectedReport>();
const [selectedCategory, setSelectedCategory] =
React.useState<ReportCategory>(null);
const [pdfReport, setPdfReport] = React.useState<{
blob: Blob;
fileName: string;
mimeType: string;
} | null>(null);
const [isEmailDialogOpen, setIsEmailDialogOpen] = React.useState(false);
const [isEmailLoading, setIsEmailLoading] = React.useState(false);
const [pendingEmailAction, setPendingEmailAction] = React.useState<
| {
type: 'pdf';
data: { fromDate: Date; toDate: Date };
}
| {
type: 'word-excel';
data: { format: FileFormat; creditProgramIds: string[] };
}
| null
>(null);
const handleGeneratePdf = async (fromDate: Date, toDate: Date) => {
try {
const blob = await reportsApi.getPdfReport(
format(fromDate, 'yyyy-MM-dd'),
format(toDate, 'yyyy-MM-dd'),
);
setPdfReport({
blob,
fileName: `report-${format(new Date(), 'yyyy-MM-dd')}.pdf`,
mimeType: 'application/pdf',
});
toast.success('PDF отчет успешно сгенерирован');
} catch (error) {
toast.error('Ошибка при генерации PDF отчета');
console.error(error);
}
};
const handleDownloadPdf = async (fromDate: Date, toDate: Date) => {
try {
const blob = await reportsApi.getPdfReport(
format(fromDate, 'yyyy-MM-dd'),
format(toDate, 'yyyy-MM-dd'),
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pdf-report-${format(new Date(), 'yyyy-MM-dd')}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('PDF отчет успешно скачан');
} catch (error) {
toast.error('Ошибка при скачивании PDF отчета');
console.error(error);
}
};
const handleSendPdfEmail = (fromDate: Date, toDate: Date) => {
setPendingEmailAction({
type: 'pdf',
data: { fromDate, toDate },
});
setIsEmailDialogOpen(true);
};
const handleDownloadWordExcel = async (
fileFormat: FileFormat,
creditProgramIds: string[],
) => {
try {
console.log('cpIds>>', creditProgramIds);
const blob =
fileFormat === 'doc'
? await reportsApi.getWordReport(creditProgramIds)
: await reportsApi.getExcelReport(creditProgramIds);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileFormat}-report-${format(new Date(), 'yyyy-MM-dd')}.${
fileFormat === 'doc' ? 'docx' : 'xlsx'
}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${fileFormat.toUpperCase()} отчет успешно скачан`);
} catch (error) {
toast.error(`Ошибка при скачивании ${fileFormat.toUpperCase()} отчета`);
console.error(error);
}
};
const handleSendWordExcelEmail = (
fileFormat: FileFormat,
creditProgramIds: string[],
) => {
setPendingEmailAction({
type: 'word-excel',
data: { format: fileFormat, creditProgramIds },
});
setIsEmailDialogOpen(true);
};
const handleEmailSubmit = async (emailData: {
email: string;
subject: string;
body: string;
}) => {
if (!pendingEmailAction) {
toast.error('Нет данных для отправки');
return;
}
setIsEmailLoading(true);
try {
if (pendingEmailAction.type === 'pdf') {
const { fromDate, toDate } = pendingEmailAction.data;
const mailInfo: DepositReportMailSendInfoBindingModel = {
email: emailData.email,
toEmail: emailData.email,
subject: emailData.subject,
body: emailData.body,
fromDate: format(fromDate, 'yyyy-MM-dd'),
toDate: format(toDate, 'yyyy-MM-dd'),
};
await reportsApi.sendPdfReportByEmail(
mailInfo,
format(fromDate, 'yyyy-MM-dd'),
format(toDate, 'yyyy-MM-dd'),
);
} else {
const { format: fileFormat, creditProgramIds } =
pendingEmailAction.data;
const mailInfo: CreditProgramReportMailSendInfoBindingModel = {
email: emailData.email,
toEmail: emailData.email,
subject: emailData.subject,
body: emailData.body,
creditProgramIds,
};
if (fileFormat === 'doc') {
await reportsApi.sendWordReportByEmail(mailInfo);
} else {
await reportsApi.sendExcelReportByEmail(mailInfo);
}
}
toast.success('Отчет успешно отправлен на email');
setIsEmailDialogOpen(false);
setPendingEmailAction(null);
} catch (error) {
toast.error('Ошибка при отправке отчета на email');
console.error(error);
} finally {
setIsEmailLoading(false);
}
};
const handleCategoryChange = (category: ReportCategory) => {
setSelectedCategory(category);
// Сбрасываем PDF отчет при смене категории
if (category !== 'pdf') {
setPdfReport(null);
}
};
return (
<main className="flex">
<ReportSidebar
onWordClick={() => setSelectedReport('word')}
onPdfClick={() => setSelectedReport('pdf')}
onExcelClick={() => setSelectedReport('excel')}
<>
<div className="flex h-screen">
<ReportSidebar
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<ReportViewer
selectedCategory={selectedCategory}
onGeneratePdf={handleGeneratePdf}
onDownloadPdf={handleDownloadPdf}
onSendPdfEmail={handleSendPdfEmail}
onDownloadWordExcel={handleDownloadWordExcel}
onSendWordExcelEmail={handleSendWordExcelEmail}
pdfReport={pdfReport}
/>
</div>
<EmailDialog
isOpen={isEmailDialogOpen}
onClose={() => {
setIsEmailDialogOpen(false);
setPendingEmailAction(null);
}}
onSubmit={handleEmailSubmit}
isLoading={isEmailLoading}
defaultSubject={
pendingEmailAction?.type === 'pdf'
? 'Отчет по вкладам и кредитным программам по валютам'
: pendingEmailAction?.data.format === 'doc'
? 'Word отчет по вкладам по кредитным программам'
: 'Excel отчет по вкладам по кредитным программам'
}
defaultBody={
pendingEmailAction?.type === 'pdf'
? `Отчет по вкладам и кредитным программам по валютам за период с ${
pendingEmailAction.data.fromDate
? format(pendingEmailAction.data.fromDate, 'dd.MM.yyyy')
: ''
} по ${
pendingEmailAction.data.toDate
? format(pendingEmailAction.data.toDate, 'dd.MM.yyyy')
: ''
}`
: 'В приложении находится отчет по вкладам по кредитным программам.'
}
/>
<ReportViewer selectedReport={selectedReport} />
</main>
</>
);
};

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
const emailSchema = z.object({
email: z.string().email('Введите корректный email адрес'),
subject: z.string().min(1, 'Тема письма обязательна'),
body: z.string().min(1, 'Текст письма обязателен'),
});
type EmailFormData = z.infer<typeof emailSchema>;
interface EmailDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: EmailFormData) => void;
isLoading?: boolean;
defaultSubject?: string;
defaultBody?: string;
}
export const EmailDialog: React.FC<EmailDialogProps> = ({
isOpen,
onClose,
onSubmit,
isLoading = false,
defaultSubject = '',
defaultBody = '',
}) => {
const form = useForm<EmailFormData>({
resolver: zodResolver(emailSchema),
defaultValues: {
email: '',
subject: defaultSubject,
body: defaultBody,
},
});
React.useEffect(() => {
if (isOpen) {
form.reset({
email: '',
subject: defaultSubject,
body: defaultBody,
});
}
}, [isOpen, defaultSubject, defaultBody, form]);
const handleSubmit = (data: EmailFormData) => {
onSubmit(data);
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Отправка отчета на почту</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email адрес</FormLabel>
<FormControl>
<Input
placeholder="example@example.com"
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Тема письма</FormLabel>
<FormControl>
<Input placeholder="Тема письма" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>Текст письма</FormLabel>
<FormControl>
<Textarea
placeholder="Текст письма..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Отмена
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Отправка...' : 'Отправить'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,153 @@
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
export interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
value: string[];
onChange: (value: string[]) => void;
className?: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
value,
onChange,
className,
}) => {
const [open, setOpen] = React.useState(false);
const selectedLabels = value.map(
(v) => options.find((opt) => opt.value === v)?.label,
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between', className)}
>
<div className="flex gap-1 flex-wrap">
{selectedLabels.length > 0 ? (
selectedLabels.map(
(label, i) =>
label && (
<Badge
variant="secondary"
key={label}
className="mr-1 mb-1"
onClick={(e) => {
e.stopPropagation();
const newValue = value.filter(
(_, index) => index !== i,
);
onChange(newValue);
}}
>
{label}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
),
)
) : (
<span className="text-muted-foreground">Выберите опции...</span>
)}
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Поиск..." />
<CommandEmpty>Ничего не найдено.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => {
const newValue = value.includes(option.value)
? value.filter((v) => v !== option.value)
: [...value, option.value];
onChange(newValue);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -1,39 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { creditProgramsApi } from '@/api/api';
import type { CreditProgramBindingModel } from '@/types/types';
export const useCreditPrograms = () => {
const queryClient = useQueryClient();
const {
data: creditPrograms,
isLoading,
isError,
error,
} = useQuery({
} = useQuery<CreditProgramBindingModel[]>({
queryKey: ['creditPrograms'],
queryFn: creditProgramsApi.getAll,
});
const { mutate: createCreditProgram } = useMutation({
mutationFn: creditProgramsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['creditPrograms'] });
},
});
const { mutate: updateCreditProgram } = useMutation({
mutationFn: creditProgramsApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['creditPrograms'] });
},
});
return {
creditPrograms,
isLoading,
isError,
error,
createCreditProgram,
updateCreditProgram,
};
};

View File

@@ -108,3 +108,21 @@ export interface MailSendInfoBindingModel {
body: string;
attachmentPath?: string;
}
export interface ReportMailSendInfoBindingModel
extends MailSendInfoBindingModel {
email: string;
subject: string;
body: string;
}
export interface CreditProgramReportMailSendInfoBindingModel
extends ReportMailSendInfoBindingModel {
creditProgramIds: string[];
}
export interface DepositReportMailSendInfoBindingModel
extends ReportMailSendInfoBindingModel {
fromDate: string;
toDate: string;
}

View File

@@ -9,32 +9,14 @@ export default defineConfig({
server: {
port: 26312,
cors: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
fs: {
allow: ['..', './public'],
},
},
define: {
global: 'globalThis',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'pdfjs-dist': path.resolve(
__dirname,
'./node_modules/pdfjs-dist/build/pdf',
),
},
},
optimizeDeps: {
include: ['react-pdf', 'pdfjs-dist'],
},
build: {
rollupOptions: {
output: {
manualChunks: {
pdfjs: ['pdfjs-dist'],
},
},
},
},
});

View File

@@ -4,6 +4,8 @@ import {
postData,
postLoginData,
putData,
getFileData,
postEmailData,
} from './client';
import type {
ClientBindingModel,
@@ -133,3 +135,72 @@ 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,
subject: string,
body: string,
) =>
postEmailData(
`api/Report/SendReportByDeposit?fromDate=${fromDate}&toDate=${toDate}`,
{ email, subject, body },
),
// Word отчеты по кредитным программам
getCreditProgramsWordReport: (creditProgramIds: string) =>
getFileData(`api/Report/LoadClientsByCreditProgram?${creditProgramIds}`),
getCreditProgramsDataReport: (creditProgramIds: string[]) =>
getData(
`api/Report/GetClientByCreditProgram?creditProgramIds=${creditProgramIds.join(
',',
)}`,
),
sendCreditProgramsWordReport: (
creditProgramIds: string[],
email: string,
subject: string,
body: string,
) =>
postEmailData('api/Report/SendReportByCreditProgram', {
email,
subject,
body,
creditProgramIds,
}),
// Excel отчеты по кредитным программам
getCreditProgramsExcelReport: (creditProgramIds: string) =>
getFileData(
`api/Report/LoadExcelClientByCreditProgram?${creditProgramIds}`,
),
sendCreditProgramsExcelReport: (
creditProgramIds: string[],
email: string,
subject: string,
body: string,
) =>
postEmailData('api/Report/SendExcelReportByCreditProgram', {
email,
subject,
body,
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,159 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
const emailSchema = z.object({
email: z.string().email('Введите корректный email адрес'),
subject: z.string().min(1, 'Введите тему письма'),
body: z.string().min(1, 'Введите текст письма'),
});
type EmailFormData = z.infer<typeof emailSchema>;
interface EmailDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: EmailFormData) => void;
isLoading?: boolean;
title?: string;
description?: string;
defaultSubject?: string;
defaultBody?: string;
}
export const EmailDialog = ({
isOpen,
onClose,
onSubmit,
isLoading = false,
title = 'Отправить отчет на почту',
description = 'Заполните данные для отправки отчета',
defaultSubject = 'Отчет из банковской системы',
defaultBody = 'Во вложении находится запрошенный отчет.',
}: EmailDialogProps) => {
const form = useForm<EmailFormData>({
resolver: zodResolver(emailSchema),
defaultValues: {
email: '',
subject: defaultSubject,
body: defaultBody,
},
});
React.useEffect(() => {
if (isOpen) {
form.reset({
email: '',
subject: defaultSubject,
body: defaultBody,
});
}
}, [isOpen, form, defaultSubject, defaultBody]);
const handleSubmit = (data: EmailFormData) => {
onSubmit(data);
onClose();
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email адрес</FormLabel>
<FormControl>
<Input
placeholder="example@email.com"
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Тема письма</FormLabel>
<FormControl>
<Input placeholder="Тема письма" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>Текст письма</FormLabel>
<FormControl>
<Textarea
placeholder="Текст письма"
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Отмена
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Отправка...' : 'Отправить'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

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-70 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"
onClick={() => onCategorySelect('deposits')}
>
Отчеты по депозитам
</Button>
<Button
variant={
selectedCategory === 'creditPrograms' ? 'default' : 'outline'
}
className="w-full text-wrap p-5"
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,530 @@
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 { EmailDialog } from './EmailDialog';
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,
subject: string,
body: 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();
// Состояние для EmailDialog
const [isEmailDialogOpen, setIsEmailDialogOpen] = React.useState(false);
const [emailDialogData, setEmailDialogData] = React.useState<{
type: string;
data: Record<string, unknown>;
defaultSubject: string;
defaultBody: string;
} | null>(null);
// Формы для разных типов отчетов
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) => {
setEmailDialogData({
type: 'deposits-pdf',
data: {
fromDate: format(data.fromDate, 'yyyy-MM-dd'),
toDate: format(data.toDate, 'yyyy-MM-dd'),
},
defaultSubject: 'Отчет по депозитам',
defaultBody: `Отчет по депозитам за период с ${format(
data.fromDate,
'dd.MM.yyyy',
)} по ${format(data.toDate, 'dd.MM.yyyy')}.`,
});
setIsEmailDialogOpen(true);
};
// Обработчики для отчетов по кредитным программам
const handleDownloadCreditProgramsReport = (
data: CreditProgramsReportForm,
) => {
onDownloadReport(`creditPrograms-${data.format}`, {
creditProgramIds: data.creditProgramIds,
});
};
const handleSendCreditProgramsEmail = (data: CreditProgramsReportForm) => {
const selectedPrograms = data.creditProgramIds
.map((id) => creditPrograms?.find((p) => p.id === id)?.name)
.filter(Boolean)
.join(', ');
setEmailDialogData({
type: `creditPrograms-${data.format}`,
data: {
creditProgramIds: data.creditProgramIds,
},
defaultSubject: `Отчет по кредитным программам (${data.format.toUpperCase()})`,
defaultBody: `Отчет по кредитным программам: ${selectedPrograms}.`,
});
setIsEmailDialogOpen(true);
};
// Проверка валидности форм
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);
};
// Обработчик отправки email
const handleEmailSubmit = (emailData: {
email: string;
subject: string;
body: string;
}) => {
if (emailDialogData) {
onSendEmail(
emailDialogData.type,
emailDialogData.data,
emailData.email,
emailData.subject,
emailData.body,
);
}
};
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(handleSendDepositsEmail)}
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(
handleSendCreditProgramsEmail,
)}
disabled={!isCreditProgramsFormValid || isLoading}
className="flex flex-col items-center gap-1 h-auto py-2 px-3 min-w-[100px]"
>
<Mail className="h-4 w-4" />
<span className="text-xs leading-tight text-center">
Отправить на почту
</span>
</Button>
</div>
</form>
</Form>
</div>
)}
{/* Email Dialog */}
<EmailDialog
isOpen={isEmailDialogOpen}
onClose={() => setIsEmailDialogOpen(false)}
onSubmit={handleEmailSubmit}
isLoading={isLoading}
title={emailDialogData?.defaultSubject || 'Отправить отчет на почту'}
description="Заполните данные для отправки отчета"
defaultSubject={
emailDialogData?.defaultSubject || 'Отчет из банковской системы'
}
defaultBody={
emailDialogData?.defaultBody ||
'Во вложении находится запрошенный отчет.'
}
/>
</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,259 @@
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,
subject: string,
body: string,
) => {
if (type === 'deposits-pdf') {
const { fromDate, toDate } = data as { fromDate: string; toDate: string };
sendDepositsPdfReport(
{ fromDate, toDate, email, subject, body },
{
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, subject, body },
{
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, subject, body },
{
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,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,152 @@
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,
subject,
body,
}: {
fromDate: string;
toDate: string;
email: string;
subject: string;
body: string;
}) =>
reportsApi.sendDepositsPdfReport(
fromDate,
toDate,
email,
subject,
body,
),
});
// 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,
subject,
body,
}: {
creditProgramIds: string[];
email: string;
subject: string;
body: string;
}) =>
reportsApi.sendCreditProgramsWordReport(
creditProgramIds,
email,
subject,
body,
),
});
// 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,
subject,
body,
}: {
creditProgramIds: string[];
email: string;
subject: string;
body: string;
}) =>
reportsApi.sendCreditProgramsExcelReport(
creditProgramIds,
email,
subject,
body,
),
});
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>,
},