13 Commits

64 changed files with 4124 additions and 4970 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([3000, 3000, 3000, 3000], tableRows)
.AddTable([2000, 2000, 2000, 2000], tableRows)
.Build();
}
@@ -114,20 +117,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 _baseExcelBuilder
.AddHeader("Клиенты по кредитным программам", 0, 4)
.AddParagraph($"Сформировано на дату {DateTime.Now}", 0)
.AddTable([3000, 3000, 3000, 3000], tableRows)
.AddTable([25, 25, 25, 25], 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;
}
@@ -211,21 +214,21 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
foreach (var client in data)
{
tableRows.Add(new string[]
{
tableRows.Add(
[
client.ClientSurname,
client.ClientName,
client.ClientBalance.ToString("N2"),
client.DepositRate.ToString("N2"),
$"{client.DepositPeriod} мес.",
$"{client.FromPeriod.ToShortDateString()} - {client.ToPeriod.ToShortDateString()}"
});
]);
}
return _basePdfBuilder
.AddHeader("Клиенты по вкладам")
.AddParagraph($"за период с {dateStart.ToShortDateString()} по {dateFinish.ToShortDateString()}")
.AddTable([80, 80, 80, 80, 80, 80], tableRows)
.AddTable([25, 25, 25, 25, 25, 25], tableRows)
.Build();
}
@@ -289,23 +292,54 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
foreach (var currency in data)
{
// Вывод информации по кредитным программам
for (int i = 0; i < currency.CreditProgramName.Count; i++)
{
tableRows.Add(new string[]
// Вычисляем индекс депозита, если есть соответствующие
string depositRate = "—";
string depositPeriod = "—";
// Проверяем, есть ли депозиты для этой валюты и не вышли ли мы за границы массива
if (currency.DepositRate.Count > 0)
{
// Берем индекс по модулю, чтобы не выйти за границы массива
int depositIndex = i % currency.DepositRate.Count;
depositRate = currency.DepositRate[depositIndex].ToString("N2");
depositPeriod = $"{currency.DepositPeriod[depositIndex]} мес.";
}
// Добавляем строку в таблицу
tableRows.Add(
[
currency.CurrencyName,
currency.CreditProgramName[i],
currency.CreditProgramMaxCost[i].ToString("N2"),
currency.DepositRate[i].ToString("N2"),
$"{currency.DepositPeriod[i]} мес."
});
depositRate,
depositPeriod
]);
}
// Если есть депозиты, но нет кредитных программ, добавляем строки только с депозитами
if (currency.CreditProgramName.Count == 0 && currency.DepositRate.Count > 0)
{
for (int j = 0; j < currency.DepositRate.Count; j++)
{
tableRows.Add(
[
currency.CurrencyName,
"—",
"—",
currency.DepositRate[j].ToString("N2"),
$"{currency.DepositPeriod[j]} мес."
]);
}
}
}
return _basePdfBuilder
.AddHeader("Вклады и кредитные программы по валютам")
.AddParagraph($"за период с {dateStart.ToShortDateString()} по {dateFinish.ToShortDateString()}")
.AddTable([80, 100, 80, 80, 80], tableRows)
.AddTable([25, 30, 25, 25, 25], tableRows)
.Build();
}
@@ -363,20 +397,20 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
{
for (int i = 0; i < program.DepositRate.Count; i++)
{
tableRows.Add(new string[]
{
tableRows.Add(
[
program.CreditProgramName,
program.DepositRate[i].ToString("N2"),
program.DepositCost[i].ToString("N2"),
program.DepositPeriod[i].ToString()
});
]);
}
}
return _baseWordBuilder
.AddHeader("Вклады по кредитным программам")
.AddParagraph($"Сформировано на дату {DateTime.Now}")
.AddTable([3000, 3000, 3000, 3000], tableRows)
.AddTable([2000, 2000, 2000, 2000], tableRows)
.Build();
}
@@ -401,20 +435,20 @@ public class ReportContract(IClientStorageContract clientStorage, ICurrencyStora
{
for (int i = 0; i < program.DepositRate.Count; i++)
{
tableRows.Add(new string[]
{
tableRows.Add(
[
program.CreditProgramName,
program.DepositRate[i].ToString("N2"),
program.DepositCost[i].ToString("N2"),
program.DepositPeriod[i].ToString()
});
]);
}
}
return _baseExcelBuilder
.AddHeader("Вклады по кредитным программам", 0, 4)
.AddParagraph($"Сформировано на дату {DateTime.Now}", 0)
.AddTable([3000, 3000, 3000, 3000], tableRows)
.AddTable([25, 25, 25, 25], tableRows)
.Build();
}
}

View File

@@ -56,7 +56,7 @@ public class MigraDocPdfBuilder : BasePdfBuilder
// Добавляем столбцы с заданной шириной
foreach (var width in columnsWidths)
{
var widthInCm = width / 28.35;
var widthInCm = width / 10.35;
var column = table.AddColumn(Unit.FromCentimeter(widthInCm));
column.Format.Alignment = ParagraphAlignment.Left;
}

View File

@@ -18,4 +18,7 @@ public class ReportOperationResponse : OperationResponse
public static ReportOperationResponse BadRequest(string message) => BadRequest<ReportOperationResponse>(message);
public static ReportOperationResponse InternalServerError(string message) => InternalServerError<ReportOperationResponse>(message);
public Stream? GetStream() => Result as Stream;
public string? GetFileName() => FileName;
}

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

@@ -109,7 +109,7 @@ internal class DepositStorageContract : IDepositStorageContract
catch (InvalidOperationException ex) when (ex.TargetSite?.Name == "ThrowIdentityConflict")
{
_dbContext.ChangeTracker.Clear();
throw new ElementExistsException($"Id {depositDataModel.Id }");
throw new ElementExistsException($"Id {depositDataModel.Id}");
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { ConstraintName: "IX_Deposits_InterestRate" })
{
@@ -127,40 +127,76 @@ internal class DepositStorageContract : IDepositStorageContract
{
try
{
var transaction = _dbContext.Database.BeginTransaction();
using var transaction = _dbContext.Database.BeginTransaction();
try
{
var element = GetDepositById(depositDataModel.Id) ?? throw new ElementNotFoundException(depositDataModel.Id);
// Загружаем существующий вклад со связями
var existingDeposit = _dbContext.Deposits
.Include(d => d.DepositCurrencies)
.FirstOrDefault(d => d.Id == depositDataModel.Id);
if (existingDeposit == null)
{
throw new ElementNotFoundException(depositDataModel.Id);
}
// Обновляем основные поля вклада
existingDeposit.InterestRate = depositDataModel.InterestRate;
existingDeposit.Cost = depositDataModel.Cost;
existingDeposit.Period = depositDataModel.Period;
existingDeposit.ClerkId = depositDataModel.ClerkId;
// Обновляем связи с валютами, если они переданы
if (depositDataModel.Currencies != null)
{
if (element.DepositCurrencies != null || element.DepositCurrencies?.Count >= 0)
// Удаляем все существующие связи
if (existingDeposit.DepositCurrencies != null)
{
_dbContext.DepositCurrencies.RemoveRange(element.DepositCurrencies);
_dbContext.DepositCurrencies.RemoveRange(existingDeposit.DepositCurrencies);
}
element.DepositCurrencies = _mapper.Map<List<DepositCurrency>>(depositDataModel.Currencies);
// Сохраняем изменения для применения удаления
_dbContext.SaveChanges();
// Создаем новые связи
existingDeposit.DepositCurrencies = depositDataModel.Currencies.Select(c =>
new DepositCurrency
{
DepositId = existingDeposit.Id,
CurrencyId = c.CurrencyId
}).ToList();
}
_mapper.Map(depositDataModel, element);
// Сохраняем все изменения
_dbContext.SaveChanges();
transaction.Commit();
// Выводим отладочную информацию
System.Console.WriteLine($"Updated deposit {existingDeposit.Id} with {existingDeposit.DepositCurrencies?.Count ?? 0} currency relations");
foreach (var relation in existingDeposit.DepositCurrencies ?? Enumerable.Empty<DepositCurrency>())
{
System.Console.WriteLine($"Currency relation: DepositId={relation.DepositId}, CurrencyId={relation.CurrencyId}");
}
}
catch
catch (Exception ex)
{
transaction.Rollback();
throw;
System.Console.WriteLine($"Error in transaction: {ex.Message}");
if (ex is ElementNotFoundException)
throw;
throw new StorageException(ex.Message);
}
}
catch (ElementNotFoundException)
{
_dbContext.ChangeTracker.Clear();
throw;
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { ConstraintName: "IX_Deposits_InterestRate" })
{
_dbContext.ChangeTracker.Clear();
throw new ElementExistsException($"InterestRate {depositDataModel.InterestRate}");
}
catch (ElementNotFoundException)
{
_dbContext.ChangeTracker.Clear();
throw;
}
catch (Exception ex)
{
_dbContext.ChangeTracker.Clear();

View File

@@ -5,5 +5,5 @@ namespace BankTests.Infrastructure;
internal class ConfigurationDatabase : IConfigurationDatabase
{
public string ConnectionString =>
"Host=127.0.0.1;Port=5432;Database=TitanicTest;Username=postgres;Password=postgres;Include Error Detail=true";
"Host=127.0.0.1;Port=5432;Database=TitanicTest;Username=postgres;Password=admin123;Include Error Detail=true";
}

View File

@@ -23,12 +23,34 @@ public class DepositAdapter : IDepositAdapter
_logger = logger;
var config = new MapperConfiguration(cfg =>
{
cfg.CreateMap<DepositBindingModel, DepositDataModel>();
cfg.CreateMap<DepositDataModel, DepositViewModel>();
cfg.CreateMap<DepositCurrencyBindingModel, DepositCurrencyDataModel>();
// DepositBindingModel -> DepositDataModel
cfg.CreateMap<DepositBindingModel, DepositDataModel>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id ?? string.Empty))
.ForMember(dest => dest.InterestRate, opt => opt.MapFrom(src => src.InterestRate))
.ForMember(dest => dest.Cost, opt => opt.MapFrom(src => src.Cost))
.ForMember(dest => dest.Period, opt => opt.MapFrom(src => src.Period))
.ForMember(dest => dest.ClerkId, opt => opt.MapFrom(src => src.ClerkId ?? string.Empty))
.ForMember(dest => dest.Currencies, opt => opt.MapFrom(src => src.DepositCurrencies ?? new List<DepositCurrencyBindingModel>()));
// DepositDataModel -> DepositViewModel
cfg.CreateMap<DepositDataModel, DepositViewModel>()
.ForMember(dest => dest.DepositCurrencies, opt => opt.MapFrom(src => src.Currencies != null ? src.Currencies : new List<DepositCurrencyDataModel>()));
// DepositCurrencyBindingModel -> DepositCurrencyDataModel
cfg.CreateMap<DepositCurrencyBindingModel, DepositCurrencyDataModel>()
.ForMember(dest => dest.DepositId, opt => opt.MapFrom(src => src.DepositId ?? string.Empty))
.ForMember(dest => dest.CurrencyId, opt => opt.MapFrom(src => src.CurrencyId ?? string.Empty));
// DepositCurrencyDataModel -> DepositCurrencyViewModel
cfg.CreateMap<DepositCurrencyDataModel, DepositCurrencyViewModel>();
cfg.CreateMap<DepositClientBindingModel, DepositClientDataModel>()
.ConstructUsing(src => new DepositClientDataModel(src.DepositId, src.ClientId));
// DepositCurrencyViewModel -> DepositCurrencyBindingModel
cfg.CreateMap<DepositCurrencyViewModel, DepositCurrencyBindingModel>();
// Явный маппинг DepositCurrencyDataModel -> DepositCurrencyBindingModel
cfg.CreateMap<DepositCurrencyDataModel, DepositCurrencyBindingModel>()
.ForMember(dest => dest.DepositId, opt => opt.MapFrom(src => src.DepositId))
.ForMember(dest => dest.CurrencyId, opt => opt.MapFrom(src => src.CurrencyId));
});
_mapper = new Mapper(config);
}

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,27 +13,31 @@
"@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",
"pdfjs-dist": "^5.2.133",
"react": "^19.1.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-pdf": "^9.2.1",
"react-router-dom": "^7.6.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><path fill="#169154" d="M29,6H15.744C14.781,6,14,6.781,14,7.744v7.259h15V6z"/><path fill="#18482a" d="M14,33.054v7.202C14,41.219,14.781,42,15.743,42H29v-8.946H14z"/><path fill="#0c8045" d="M14 15.003H29V24.005000000000003H14z"/><path fill="#17472a" d="M14 24.005H29V33.055H14z"/><g><path fill="#29c27f" d="M42.256,6H29v9.003h15V7.744C44,6.781,43.219,6,42.256,6z"/><path fill="#27663f" d="M29,33.054V42h13.257C43.219,42,44,41.219,44,40.257v-7.202H29z"/><path fill="#19ac65" d="M29 15.003H44V24.005000000000003H29z"/><path fill="#129652" d="M29 24.005H44V33.055H29z"/></g><path fill="#0c7238" d="M22.319,34H5.681C4.753,34,4,33.247,4,32.319V15.681C4,14.753,4.753,14,5.681,14h16.638 C23.247,14,24,14.753,24,15.681v16.638C24,33.247,23.247,34,22.319,34z"/><path fill="#fff" d="M9.807 19L12.193 19 14.129 22.754 16.175 19 18.404 19 15.333 24 18.474 29 16.123 29 14.013 25.07 11.912 29 9.526 29 12.719 23.982z"/></svg>

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="35px" height="35px" viewBox="-4 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z" fill="#EB5757"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 28.8125 0.03125 L 0.8125 5.34375 C 0.339844 5.433594 0 5.863281 0 6.34375 L 0 43.65625 C 0 44.136719 0.339844 44.566406 0.8125 44.65625 L 28.8125 49.96875 C 28.875 49.980469 28.9375 50 29 50 C 29.230469 50 29.445313 49.929688 29.625 49.78125 C 29.855469 49.589844 30 49.296875 30 49 L 30 1 C 30 0.703125 29.855469 0.410156 29.625 0.21875 C 29.394531 0.0273438 29.105469 -0.0234375 28.8125 0.03125 Z M 32 6 L 32 13 L 44 13 L 44 15 L 32 15 L 32 20 L 44 20 L 44 22 L 32 22 L 32 27 L 44 27 L 44 29 L 32 29 L 32 35 L 44 35 L 44 37 L 32 37 L 32 44 L 47 44 C 48.101563 44 49 43.101563 49 42 L 49 8 C 49 6.898438 48.101563 6 47 6 Z M 4.625 15.65625 L 8.1875 15.65625 L 10.21875 28.09375 C 10.308594 28.621094 10.367188 29.355469 10.40625 30.25 L 10.46875 30.25 C 10.496094 29.582031 10.613281 28.855469 10.78125 28.0625 L 13.40625 15.65625 L 16.90625 15.65625 L 19.28125 28.21875 C 19.367188 28.679688 19.433594 29.339844 19.5 30.21875 L 19.53125 30.21875 C 19.558594 29.53125 19.632813 28.828125 19.75 28.125 L 21.75 15.65625 L 25.0625 15.65625 L 21.21875 34.34375 L 17.59375 34.34375 L 15.1875 22.375 C 15.058594 21.75 14.996094 21.023438 14.96875 20.25 L 14.9375 20.25 C 14.875 21.101563 14.769531 21.824219 14.65625 22.375 L 12.1875 34.34375 L 8.4375 34.34375 Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@@ -30,9 +30,8 @@ export const clientsApi = {
// Clerks API
export const clerksApi = {
getAll: () => getData<ClerkBindingModel>('api/Clerks/GetAllRecords'),
getById: (id: string) =>
getData<ClerkBindingModel>(`api/Clerks/GetRecord/${id}`),
getAll: () => getData<ClerkBindingModel>('api/Clerks'),
getById: (id: string) => getData<ClerkBindingModel>(`api/Clerks/${id}`),
create: (data: ClerkBindingModel) => postData('api/Clerks/Register', data),
update: (data: ClerkBindingModel) => putData('api/Clerks/ChangeInfo', data),
};
@@ -129,3 +128,8 @@ export const storekeepersApi = {
getCurrentUser: () =>
getSingleData<StorekeeperBindingModel>('api/storekeepers/me'),
};
//Reports API
export const reportsApi = {
// loadClientsByCreditProgram: () => getReport('path'),
};

View File

@@ -1,6 +1,8 @@
import { ConfigManager } from '@/lib/config';
import type { MailSendInfoBindingModel } from '@/types/types';
const API_URL = ConfigManager.loadUrl();
// Устанавливаем прямой URL к API серверу ASP.NET
// const API_URL = 'https://localhost:7224'; // URL API сервера ASP.NET
export async function getData<T>(path: string): Promise<T[]> {
const res = await fetch(`${API_URL}/${path}`, {
@@ -69,3 +71,54 @@ export async function putData<T>(path: string, data: T) {
throw new Error(`Не получается загрузить ${path}: ${res.statusText}`);
}
}
// report api
export interface ReportParams {
fromDate?: string; // Например, '2025-01-01'
toDate?: string; // Например, '2025-05-21'
}
export type ReportType =
| 'depositByCreditProgram'
| 'depositAndCreditProgramByCurrency';
export type ReportFormat = 'word' | 'excel' | 'pdf';
export async function sendReportByEmail(
reportType: ReportType,
format: ReportFormat,
mailInfo: MailSendInfoBindingModel,
params?: ReportParams,
): Promise<void> {
const actionMap: Record<ReportType, Record<ReportFormat, string>> = {
depositByCreditProgram: {
word: 'SendReportDepositByCreditProgram',
excel: 'SendExcelReportDepositByCreditProgram',
pdf: 'SendReportDepositByCreditProgram',
},
depositAndCreditProgramByCurrency: {
word: 'SendReportByCurrency',
excel: 'SendReportByCurrency',
pdf: 'SendReportByCurrency',
},
};
const action = actionMap[reportType][format];
// Формируем тело запроса
const requestBody = { ...mailInfo, ...params };
const res = await fetch(`${API_URL}/api/Report/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(requestBody),
});
if (!res.ok) {
throw new Error(
`Не удалось отправить отчет ${reportType} (${format}): ${res.statusText}`,
);
}
}

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

@@ -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,342 @@
import React from 'react';
import { PdfViewer } from './PdfViewer';
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, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type ReportCategory = 'pdf' | 'word-excel' | null;
type FileFormat = 'doc' | 'xls';
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 = ({
selectedCategory,
onGeneratePdf,
onDownloadPdf,
onSendPdfEmail,
onDownloadWordExcel,
onSendWordExcelEmail,
pdfReport,
}: ReportViewerProps): React.JSX.Element => {
const { creditPrograms } = useCreditPrograms();
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[]>([]);
const isPdfDatesValid = fromDate && toDate;
const isWordExcelDataValid = selectedCreditProgramIds.length > 0;
const handleCreditProgramSelect = (creditProgramId: string) => {
setSelectedCreditProgramIds((prev) => {
if (prev.includes(creditProgramId)) {
return prev.filter((id) => id !== creditProgramId);
} else {
return [...prev, creditProgramId];
}
});
};
const removeCreditProgram = (creditProgramId: string) => {
setSelectedCreditProgramIds((prev) =>
prev.filter((id) => id !== creditProgramId),
);
};
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>
);
}
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>
{/* Выбор дат */}
<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>
<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="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>
{/* Область просмотра 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>
);
}
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

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

View File

@@ -0,0 +1,64 @@
import React from 'react';
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;
}
export const ReportSidebar = ({
selectedCategory,
onCategoryChange,
}: ReportSidebarProps): React.JSX.Element => {
return (
<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

@@ -94,6 +94,7 @@ export const CreditPrograms = (): React.JSX.Element => {
>();
const handleAdd = (data: CreditProgramBindingModel) => {
console.log('add', data);
createCreditProgram(data);
setIsAddDialogOpen(false);
};
@@ -104,6 +105,7 @@ export const CreditPrograms = (): React.JSX.Element => {
...selectedItem,
...data,
});
console.log('edit', data);
setIsEditDialogOpen(false);
setSelectedItem(undefined);
}
@@ -116,7 +118,7 @@ export const CreditPrograms = (): React.JSX.Element => {
const openEditForm = () => {
if (!selectedItem) {
toast('Выберите элемент для редактирования');
toast.error('Выберите элемент для редактирования');
return;
}

View File

@@ -75,6 +75,7 @@ export const Currencies = (): React.JSX.Element => {
>();
const handleAdd = (data: CurrencyBindingModel) => {
console.log('add', data);
createCurrency(data);
setIsAddDialogOpen(false);
};
@@ -85,6 +86,7 @@ export const Currencies = (): React.JSX.Element => {
...selectedItem,
...data,
});
console.log('edit', data);
setIsEditDialogOpen(false);
setSelectedItem(undefined);
}
@@ -97,7 +99,7 @@ export const Currencies = (): React.JSX.Element => {
const openEditForm = () => {
if (!selectedItem) {
toast('Выберите элемент для редактирования');
toast.error('Выберите элемент для редактирования');
return;
}
@@ -132,6 +134,7 @@ export const Currencies = (): React.JSX.Element => {
description="Добавьте новую валюту"
isOpen={isAddDialogOpen}
onClose={() => setIsAddDialogOpen(false)}
onSubmit={handleAdd}
>
<CurrencyFormAdd onSubmit={handleAdd} />
</DialogForm>

View File

@@ -0,0 +1,367 @@
import React from 'react';
import { useDeposits } from '@/hooks/useDeposits';
import { useCurrencies } from '@/hooks/useCurrencies';
import { useClerks } from '@/hooks/useClerks';
import { DataTable, type ColumnDef } from '../layout/DataTable';
import { AppSidebar } from '../layout/Sidebar';
import { DialogForm } from '../layout/DialogForm';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type {
DepositBindingModel,
DepositCurrencyBindingModel,
} from '@/types/types';
type DepositRowData = DepositBindingModel & {
clerkName: string;
currenciesDisplay: string;
};
const columns: ColumnDef<DepositRowData>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'interestRate',
header: 'Процентная ставка',
},
{
accessorKey: 'cost',
header: 'Стоимость',
},
{
accessorKey: 'period',
header: 'Срок вклада',
},
{
accessorKey: 'clerkName',
header: 'Клерк',
},
{
accessorKey: 'currenciesDisplay',
header: 'Валюты',
},
];
type FormValues = {
currencyIds: string[];
};
const schema = z.object({
currencyIds: z.array(z.string()),
});
const DepositCurrencyForm = ({
onSubmit,
defaultValues,
}: {
onSubmit: (data: { currencyIds: string[] }) => void;
defaultValues: Partial<DepositBindingModel>;
}): React.JSX.Element => {
const { currencies } = useCurrencies();
const initialCurrencyIds = React.useMemo(
() =>
defaultValues?.depositCurrencies
?.map((dc) => dc.currencyId)
.filter((id): id is string => !!id) || [],
[defaultValues?.depositCurrencies],
);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
currencyIds: initialCurrencyIds,
},
});
React.useEffect(() => {
if (defaultValues) {
form.reset({
currencyIds: initialCurrencyIds,
});
}
}, [defaultValues, form, initialCurrencyIds]);
const handleSubmit = (data: FormValues) => {
onSubmit(data);
};
const selectedCurrencyIds = form.watch('currencyIds') || [];
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 max-w-md mx-auto p-4"
>
<FormField
control={form.control}
name="currencyIds"
render={({ field }) => (
<FormItem>
<FormLabel>Валюты</FormLabel>
<Select
onValueChange={(value) => {
const currentValues = field.value || [];
if (!currentValues.includes(value)) {
field.onChange([...currentValues, value]);
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите валюты" />
</SelectTrigger>
</FormControl>
<SelectContent>
{currencies?.map((currency) => (
<SelectItem
key={currency.id}
value={currency.id || ''}
className={cn(
selectedCurrencyIds.includes(currency.id || '') &&
'bg-muted',
)}
>
{`${currency.name} (${currency.abbreviation})`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2 mt-2">
{selectedCurrencyIds.map((currencyId) => {
const currency = currencies?.find((c) => c.id === currencyId);
return (
<div
key={currencyId}
className="bg-muted px-2 py-1 rounded-md flex items-center gap-1"
>
<span>
{currency
? `${currency.name} (${currency.abbreviation})`
: currencyId}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 rounded-full"
onClick={() => {
const newValues = selectedCurrencyIds.filter(
(id) => id !== currencyId,
);
form.setValue('currencyIds', newValues);
}}
>
×
</Button>
</div>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Сохранить
</Button>
</form>
</Form>
);
};
export const DepositCurrencyManager = (): React.JSX.Element => {
const {
deposits,
isLoading: isDepositsLoading,
error: depositsError,
updateDeposit,
} = useDeposits();
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies();
const { clerks, isLoading: isClerksLoading } = useClerks();
const [isEditDialogOpen, setIsEditDialogOpen] =
React.useState<boolean>(false);
const [selectedItem, setSelectedItem] = React.useState<
DepositBindingModel | undefined
>();
const finalData = React.useMemo(() => {
if (!deposits || !currencies || !clerks) return [];
return deposits.map((deposit) => {
// Находим клерка по ID
const clerk = clerks.find((c) => c.id === deposit.clerkId);
// Формирование списка валют
const currenciesDisplay =
deposit.depositCurrencies
?.map((dc) => {
const currency = currencies?.find((c) => c.id === dc.currencyId);
return currency
? `${currency.name} (${currency.abbreviation})`
: dc.currencyId;
})
.join(', ') || 'Нет валют';
return {
...deposit,
clerkName: clerk
? `${clerk.name} ${clerk.surname}`
: 'Неизвестный клерк',
currenciesDisplay,
};
});
}, [deposits, currencies, clerks]);
const handleEdit = (data: { currencyIds: string[] }) => {
if (selectedItem) {
// Проверка на дублирование валют
const uniqueCurrencyIds = new Set(data.currencyIds);
if (uniqueCurrencyIds.size !== data.currencyIds.length) {
toast.error(
'Обнаружены дублирующиеся валюты. Пожалуйста, убедитесь что каждая валюта выбрана только один раз.',
);
return;
}
// Формируем массив связей, сохраняя существующие ID где это возможно
const depositCurrencies: DepositCurrencyBindingModel[] =
data.currencyIds.map((currencyId) => {
// Ищем существующую связь с этой валютой
const existingRelation = selectedItem.depositCurrencies?.find(
(dc) => dc.currencyId === currencyId,
);
// Если связь уже существует, возвращаем её с оригинальным ID
if (existingRelation) {
return { ...existingRelation };
}
// Если это новая связь, создаем объект без ID
return {
currencyId,
depositId: selectedItem.id,
};
});
console.log('Обновляем депозит с данными:', {
...selectedItem,
depositCurrencies,
});
// Обновляем вклад, сохраняя все оригинальные поля и связи
updateDeposit({
...selectedItem, // Сохраняем все существующие поля
depositCurrencies, // Обновляем только связи с валютами
});
setIsEditDialogOpen(false);
setSelectedItem(undefined);
toast.success('Связи валют с вкладом успешно обновлены');
}
};
const handleSelectItem = (id: string | undefined) => {
const item = deposits?.find((p) => p.id === id);
if (item) {
setSelectedItem({
...item,
});
} else {
setSelectedItem(undefined);
}
};
const openEditForm = () => {
if (!selectedItem) {
toast('Выберите вклад для добавления валют');
return;
}
setIsEditDialogOpen(true);
};
if (isDepositsLoading || isCurrenciesLoading || isClerksLoading) {
return <main className="container mx-auto py-10">Загрузка...</main>;
}
if (depositsError) {
return (
<main className="container mx-auto py-10">
Ошибка загрузки данных: {depositsError.message}
</main>
);
}
return (
<main className="flex-1 flex relative">
<AppSidebar
onAddClick={() => {
toast(
'Кладовщик не может создавать вклады, только связывать их с валютами',
);
}}
onEditClick={() => {
openEditForm();
}}
/>
<div className="flex-1 p-4">
{selectedItem && (
<DialogForm
title="Управление валютами вклада"
description="Выберите валюты для связи с вкладом"
isOpen={isEditDialogOpen}
onClose={() => setIsEditDialogOpen(false)}
onSubmit={handleEdit}
>
<DepositCurrencyForm
onSubmit={handleEdit}
defaultValues={selectedItem}
/>
</DialogForm>
)}
<div className="mb-4">
<h2 className="text-2xl font-bold">
Управление связями вкладов и валют
</h2>
<p className="text-muted-foreground">
Кладовщик может связывать существующие вклады с валютами, но не
может создавать новые вклады.
</p>
</div>
<div>
<DataTable
data={finalData}
columns={columns}
onRowSelected={(id) => handleSelectItem(id)}
selectedRow={selectedItem?.id}
/>
</div>
</div>
</main>
);
};

View File

@@ -97,7 +97,7 @@ export const Periods = (): React.JSX.Element => {
const openEditForm = () => {
if (!selectedItem) {
toast('Выберите элемент для редактирования');
toast.error('Выберите элемент для редактирования');
return;
}
@@ -127,18 +127,17 @@ export const Periods = (): React.JSX.Element => {
}}
/>
<div className="flex-1 p-4">
{!selectedItem &&
<DialogForm<PeriodBindingModel>
title="Форма сроков"
description="Добавить сроки"
isOpen={isAddDialogOpen}
onClose={() => setIsAddDialogOpen(false)}
onSubmit={handleAdd}
>
<PeriodFormAdd />
</DialogForm>
}
{!selectedItem && (
<DialogForm<PeriodBindingModel>
title="Форма сроков"
description="Добавить сроки"
isOpen={isAddDialogOpen}
onClose={() => setIsAddDialogOpen(false)}
onSubmit={handleAdd}
>
<PeriodFormAdd />
</DialogForm>
)}
{selectedItem && (
<DialogForm<PeriodBindingModel>
title="Форма сроков"
@@ -147,9 +146,7 @@ export const Periods = (): React.JSX.Element => {
onClose={() => setIsEditDialogOpen(false)}
onSubmit={handleEdit}
>
<PeriodFormEdit
defaultValues={selectedItem}
/>
<PeriodFormEdit defaultValues={selectedItem} />
</DialogForm>
)}
<div>

View File

@@ -0,0 +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';
type ReportCategory = 'pdf' | 'word-excel' | null;
type FileFormat = 'doc' | 'xls';
export const Reports = (): React.JSX.Element => {
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 (
<>
<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')
: ''
}`
: 'В приложении находится отчет по вкладам по кредитным программам.'
}
/>
</>
);
};

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

@@ -137,7 +137,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-[500px] w-full',
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-[500px] w-[300px]',
className,
)}
{...props}

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

@@ -0,0 +1,130 @@
import { useQuery } from '@tanstack/react-query';
import { type ReportParams } from '@/api/client';
const fetchReport = async (
url: string,
format: 'word' | 'excel' | 'pdf',
params?: ReportParams,
) => {
const query = params
? Object.entries(params)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value as string)}`)
.join('&')
: '';
const fullUrl = `${url}${query ? '?' + query : ''}`;
console.log(`Загрузка отчета (${format}) с URL: ${fullUrl}`);
try {
const res = await fetch(fullUrl, {
credentials: 'include',
// Добавляем явный заголовок Accept для типа возвращаемых данных
headers: {
Accept:
format === 'pdf'
? 'application/pdf'
: format === 'word'
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
});
if (!res.ok) {
console.error(
`Ошибка при загрузке отчета: ${res.status} ${res.statusText}`,
);
throw new Error(
`Не удалось загрузить отчет с URL ${fullUrl}: ${res.statusText} (${res.status})`,
);
}
const blob = await res.blob();
console.log(
`Отчет загружен. Тип содержимого: ${blob.type}, размер: ${blob.size} байт`,
);
const contentDisposition = res.headers.get('Content-Disposition');
let fileName = 'report'; // Default filename
let fileExtension = '';
// Determine file extension based on format if not in Content-Disposition
switch (format) {
case 'word':
fileExtension = '.docx';
break;
case 'excel':
fileExtension = '.xlsx';
break;
case 'pdf':
fileExtension = '.pdf';
break;
}
if (contentDisposition && contentDisposition.includes('filename=')) {
fileName = contentDisposition
.split('filename=')[1]
.replace(/"/g, '')
.trim();
// Use filename from header if available, but ensure correct extension
if (!fileName.toLowerCase().endsWith(fileExtension)) {
fileName = fileName + fileExtension;
}
} else {
// Use default filename with determined extension
fileName = fileName + fileExtension;
}
const mimeType = res.headers.get('Content-Type') || '';
console.log(`Имя файла: ${fileName}, MIME-тип: ${mimeType}`);
return { blob, fileName, mimeType };
} catch (error) {
console.error('Ошибка при загрузке отчета:', error);
throw error;
}
};
// Хук для получения Word отчета по вкладам по кредитным программам
export const useWordReport = (params?: ReportParams) => {
return useQuery({
queryKey: ['wordReport', params],
queryFn: () =>
fetchReport('/api/Report/LoadDepositByCreditProgram', 'word', params),
enabled: false, // Не загружать автоматически
staleTime: Infinity,
retry: 1,
});
};
// Хук для получения Excel отчета по вкладам по кредитным программам
export const useExcelReport = (params?: ReportParams) => {
return useQuery({
queryKey: ['excelReport', params],
queryFn: () =>
fetchReport(
'/api/Report/LoadExcelDepositByCreditProgram',
'excel',
params,
),
enabled: false, // Не загружать автоматически
staleTime: Infinity,
retry: 1,
});
};
// Хук для получения PDF отчета по вкладам и кредитным программам по валютам
export const usePdfReport = (params?: ReportParams) => {
return useQuery({
queryKey: ['pdfReport', params],
queryFn: () =>
fetchReport(
'/api/Report/LoadDepositAndCreditProgramByCurrency',
'pdf',
params,
),
enabled: false, // Не загружать автоматически
staleTime: Infinity,
retry: 1,
});
};

View File

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

View File

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

View File

@@ -27,6 +27,13 @@ export interface DepositBindingModel {
period: number;
clerkId?: string;
depositClients?: DepositClientBindingModel[];
depositCurrencies?: DepositCurrencyBindingModel[];
}
export interface DepositCurrencyBindingModel {
id?: string;
depositId?: string;
currencyId?: string;
}
export interface CurrencyBindingModel {
@@ -94,3 +101,28 @@ export interface LoginBindingModel {
login: string;
password: string;
}
export interface MailSendInfoBindingModel {
toEmail: string;
subject: string;
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

@@ -8,6 +8,11 @@ export default defineConfig({
plugins: [plugin(), tailwindcss()],
server: {
port: 26312,
cors: true,
},
define: {
global: 'globalThis',
},
resolve: {
alias: {

Binary file not shown.

View File

@@ -35,6 +35,7 @@
"react-day-picker": "8.10.1",
"react-dom": "19.1.0",
"react-hook-form": "7.56.4",
"react-pdf": "^9.2.1",
"react-router-dom": "7.6.0",
"sonner": "2.0.3",
"tailwind-merge": "^3.3.0",

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

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -12,27 +12,14 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type {
DepositBindingModel,
DepositClientBindingModel,
} from '@/types/types';
import type { DepositBindingModel } from '@/types/types';
import { useAuthStore } from '@/store/workerStore';
import { useClients } from '@/hooks/useClients';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
type BaseFormValues = {
id?: string;
interestRate: number;
cost: number;
period: number;
clientIds: string[];
};
type EditFormValues = {
@@ -40,7 +27,6 @@ type EditFormValues = {
interestRate?: number;
cost?: number;
period?: number;
clientIds?: string[];
};
const baseSchema = z.object({
@@ -50,7 +36,6 @@ const baseSchema = z.object({
.min(0, 'Процентная ставка не может быть отрицательной'),
cost: z.coerce.number().min(0, 'Стоимость не может быть отрицательной'),
period: z.coerce.number().int().min(1, 'Срок вклада должен быть не менее 1'),
clientIds: z.array(z.string()),
});
const addSchema = baseSchema;
@@ -70,7 +55,6 @@ const editSchema = z.object({
.int()
.min(1, 'Срок вклада должен быть не менее 1')
.optional(),
clientIds: z.array(z.string()).optional(),
});
interface BaseDepositFormProps {
@@ -84,16 +68,6 @@ const BaseDepositForm = ({
schema,
defaultValues,
}: BaseDepositFormProps): React.JSX.Element => {
const { clients } = useClients();
const initialClientIds = useMemo(
() =>
defaultValues?.depositClients
?.map((dc) => dc.clientId)
.filter((id): id is string => !!id) || [],
[defaultValues?.depositClients],
);
const form = useForm<BaseFormValues | EditFormValues>({
resolver: zodResolver(schema),
defaultValues: {
@@ -101,7 +75,6 @@ const BaseDepositForm = ({
interestRate: defaultValues?.interestRate || 0,
cost: defaultValues?.cost || 0,
period: defaultValues?.period || 1,
clientIds: initialClientIds,
},
});
@@ -112,29 +85,15 @@ const BaseDepositForm = ({
interestRate: defaultValues.interestRate || 0,
cost: defaultValues.cost || 0,
period: defaultValues.period || 1,
clientIds: initialClientIds,
});
}
}, [defaultValues, form, initialClientIds]);
}, [defaultValues, form]);
const clerk = useAuthStore((store) => store.user);
const handleSubmit = (data: BaseFormValues | EditFormValues) => {
const depositId = data.id || crypto.randomUUID();
const depositClients: DepositClientBindingModel[] = (
'clientIds' in data && data.clientIds ? data.clientIds : []
).map((clientId) => {
const existingDepositClient = defaultValues?.depositClients?.find(
(dc) => dc.clientId === clientId,
);
return {
id: existingDepositClient?.id, // Use existing relationship ID if available
clientId: clientId,
depositId: depositId,
};
});
const payload: DepositBindingModel = {
id: depositId,
clerkId: clerk?.id,
@@ -144,14 +103,11 @@ const BaseDepositForm = ({
: 0,
cost: 'cost' in data && data.cost !== undefined ? data.cost : 0,
period: 'period' in data && data.period !== undefined ? data.period : 1,
depositClients: depositClients,
};
onSubmit(payload);
};
const selectedClientIds = form.watch('clientIds') || [];
return (
<Form {...form}>
<form
@@ -214,67 +170,6 @@ const BaseDepositForm = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientIds"
render={({ field }) => (
<FormItem>
<FormLabel>Клиенты</FormLabel>
<Select
onValueChange={(value) => {
const currentValues = field.value || [];
if (!currentValues.includes(value)) {
field.onChange([...currentValues, value]);
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Выберите клиентов" />
</SelectTrigger>
</FormControl>
<SelectContent>
{clients?.map((client) => (
<SelectItem
key={client.id}
value={client.id || ''}
className={cn(
selectedClientIds.includes(client.id || '') &&
'bg-muted',
)}
>
{`${client.name} ${client.surname}`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2 mt-2">
{field.value?.map((id) => {
const client = clients?.find((c) => c.id === id);
return client ? (
<div
key={id}
className="bg-secondary px-2 py-1 rounded-md text-sm flex items-center gap-2"
>
<span>{`${client.name} ${client.surname}`}</span>
<button
type="button"
onClick={() => {
field.onChange(field.value?.filter((v) => v !== id));
}}
className="text-destructive hover:text-destructive/80"
>
×
</button>
</div>
) : null;
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Сохранить
</Button>

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

@@ -246,3 +246,6 @@ export const ReplenishmentFormEdit = ({
/>
);
};

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

@@ -1,5 +1,7 @@
import { useClients } from '@/hooks/useClients';
import { useClerks } from '@/hooks/useClerks';
import { useDeposits } from '@/hooks/useDeposits';
import { useCreditPrograms } from '@/hooks/useCreditPrograms';
import React from 'react';
import { DataTable, type ColumnDef } from '../layout/DataTable';
import { AppSidebar } from '../layout/Sidebar';
@@ -8,7 +10,13 @@ import { DialogForm } from '../layout/DialogForm';
import { ClientFormAdd, ClientFormEdit } from '../features/ClientForm';
import { toast } from 'sonner';
const columns: ColumnDef<ClientBindingModel>[] = [
const columns: ColumnDef<
ClientBindingModel & {
clerkName?: string;
depositsList?: string;
creditProgramsList?: string;
}
>[] = [
{
accessorKey: 'id',
header: 'ID',
@@ -30,11 +38,11 @@ const columns: ColumnDef<ClientBindingModel>[] = [
header: 'Клерк',
},
{
accessorKey: 'deposits',
accessorKey: 'depositsList',
header: 'Вклады',
},
{
accessorKey: 'creditPrograms',
accessorKey: 'creditProgramsList',
header: 'Кредиты',
},
];
@@ -52,6 +60,9 @@ export const Clients = (): React.JSX.Element => {
isLoading: isClerksLoading,
error: clerksError,
} = useClerks();
const { deposits, isLoading: isDepositsLoading } = useDeposits();
const { creditPrograms, isLoading: isCreditProgramsLoading } =
useCreditPrograms();
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState<boolean>(false);
const [isEditDialogOpen, setIsEditDialogOpen] =
@@ -61,16 +72,41 @@ export const Clients = (): React.JSX.Element => {
>();
const finalData = React.useMemo(() => {
if (!clients || !clerks) return [];
if (!clients || !clerks || !deposits || !creditPrograms) return [];
return clients.map((client) => {
const clerk = clerks.find((c) => c.id === client.clerkId);
const clientDeposits =
deposits?.filter((deposit) =>
client.depositClients?.some((dc) => dc.depositId === deposit.id),
) || [];
// Находим кредитные программы клиента
const clientCreditPrograms = creditPrograms.filter((creditProgram) =>
client.creditProgramClients?.some(
(cpc) => cpc.creditProgramId === creditProgram.id,
),
);
const depositsList =
clientDeposits && clientDeposits.length > 0
? clientDeposits.map((d) => `Вклад ${d.interestRate}%`).join(', ')
: 'Нет вкладов';
const creditProgramsList =
clientCreditPrograms.length > 0
? clientCreditPrograms.map((cp) => cp.name).join(', ')
: 'Нет кредитов';
return {
...client,
clerkName: clerk ? `${clerk.name} ${clerk.surname}` : 'Неизвестно',
depositsList,
creditProgramsList,
};
});
}, [clients, clerks]);
}, [clients, clerks, deposits, creditPrograms]);
const handleAdd = (data: ClientBindingModel) => {
createClient(data);
@@ -108,7 +144,12 @@ export const Clients = (): React.JSX.Element => {
setIsEditDialogOpen(true);
};
if (isClientsLoading || isClerksLoading) {
if (
isClientsLoading ||
isClerksLoading ||
isDepositsLoading ||
isCreditProgramsLoading
) {
return <main className="container mx-auto py-10">Загрузка...</main>;
}
@@ -139,7 +180,7 @@ export const Clients = (): React.JSX.Element => {
onClose={() => setIsAddDialogOpen(false)}
onSubmit={handleAdd}
>
<ClientFormAdd />
<ClientFormAdd onSubmit={handleAdd} />
</DialogForm>
)}
{selectedItem && (
@@ -151,7 +192,7 @@ export const Clients = (): React.JSX.Element => {
onSubmit={handleEdit}
>
<ClientFormEdit
onSubmit={console.log}
onSubmit={handleEdit}
defaultValues={selectedItem}
/>
</DialogForm>

View File

@@ -1,16 +1,17 @@
import { useDeposits } from '@/hooks/useDeposits';
import { useClerks } from '@/hooks/useClerks';
import { useCurrencies } from '@/hooks/useCurrencies';
import React from 'react';
import { AppSidebar } from '../layout/Sidebar';
import { DataTable, type ColumnDef } from '../layout/DataTable';
import type { DepositBindingModel, ClientBindingModel } from '@/types/types';
import type { DepositBindingModel } from '@/types/types';
import { DialogForm } from '../layout/DialogForm';
import { DepositFormAdd, DepositFormEdit } from '../features/DepositForm';
import { toast } from 'sonner';
type DepositRowData = DepositBindingModel & {
clerkName: string;
clientsDisplay: string;
currenciesDisplay: string;
};
const columns: ColumnDef<DepositRowData>[] = [
@@ -35,8 +36,8 @@ const columns: ColumnDef<DepositRowData>[] = [
header: 'Клерк',
},
{
accessorKey: 'clientsDisplay',
header: 'Клиенты',
accessorKey: 'currenciesDisplay',
header: 'Валюты',
},
];
@@ -53,6 +54,7 @@ export const Deposits = (): React.JSX.Element => {
isLoading: isClerksLoading,
error: clerksError,
} = useClerks();
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies();
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState<boolean>(false);
const [isEditDialogOpen, setIsEditDialogOpen] =
@@ -62,33 +64,39 @@ export const Deposits = (): React.JSX.Element => {
>();
const finalData = React.useMemo(() => {
if (!deposits || !clerks) return [];
if (!deposits || !clerks || !currencies) return [];
return deposits.map((deposit) => {
const clerk = clerks.find((c) => c.id === deposit.clerkId);
const clientsDisplay =
deposit.depositClients
// Формирование списка валют
const currenciesDisplay =
deposit.depositCurrencies
?.map((dc) => {
const client = clerks?.find((c) => c.id === dc.clientId);
return client ? `${client.name} ${client.surname}` : dc.clientId;
const currency = currencies?.find((c) => c.id === dc.currencyId);
return currency
? `${currency.name} (${currency.abbreviation})`
: dc.currencyId;
})
.join(', ') || 'Нет клиентов';
.join(', ') || 'Нет валют';
return {
...deposit,
clerkName: clerk ? `${clerk.name} ${clerk.surname}` : 'Неизвестно',
clientsDisplay: clientsDisplay,
currenciesDisplay,
};
});
}, [deposits, clerks]);
}, [deposits, clerks, currencies]);
const handleAdd = (data: DepositBindingModel) => {
console.log('Добавление вклада с данными:', data);
createDeposit(data);
setIsAddDialogOpen(false);
};
const handleEdit = (data: DepositBindingModel) => {
if (selectedItem) {
console.log('Обновление вклада с данными:', { ...selectedItem, ...data });
updateDeposit({
...selectedItem,
...data,
@@ -118,7 +126,7 @@ export const Deposits = (): React.JSX.Element => {
setIsEditDialogOpen(true);
};
if (isDepositsLoading || isClerksLoading) {
if (isDepositsLoading || isClerksLoading || isCurrenciesLoading) {
return <main className="container mx-auto py-10">Загрузка...</main>;
}

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>,
},

View File

@@ -26,7 +26,13 @@ export interface DepositBindingModel {
cost: number;
period: number;
clerkId?: string;
depositClients?: DepositClientBindingModel[];
depositCurrencies?: DepositCurrencyBindingModel[];
}
export interface DepositCurrencyBindingModel {
id?: string;
depositId?: string;
currencyId?: string;
}
export interface CurrencyBindingModel {
@@ -94,3 +100,9 @@ export interface LoginBindingModel {
login: string;
password: string;
}
export interface MailSendInfoBindingModel {
email: string;
subject?: string;
body?: string;
}