0.2.0 #4
17
README.md
17
README.md
@ -1,3 +1,16 @@
|
||||
# domBudg
|
||||
# Курсовая работа "ДомБюдж"
|
||||
## Описание
|
||||
Система "ДомБюдж" предназначена для ведения домашнего бюджета. В системе можно:
|
||||
- Вести учет трат и пополнений баланса
|
||||
- Группировать траты
|
||||
- Создавать планы трат для групп расходов
|
||||
- Получать отчет по смещению расходов относительно плана
|
||||
- Получать отчет расходов за указанный промежуток времени
|
||||
|
||||
Курсовая работа "ДомБюдж"
|
||||
## Как запустить
|
||||
Чтобы запустить, нужно ввести:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Таким образом будет запущена система в фоновом режиме.
|
||||
Чтобы перейти в веб приложение, нужно перейти на **80 порт** машины, на которой запущена система.
|
@ -6,6 +6,7 @@ namespace Contracts.Repositories;
|
||||
public interface ISpendingGroupRepo
|
||||
{
|
||||
Task<SpendingGroupDto?> Get(SpendingGroupSearch search);
|
||||
Task<SpendingGroupDto?> GetByPlan(SpendingPlanSearch search);
|
||||
Task<IEnumerable<SpendingGroupDto>> GetList(SpendingGroupSearch? search = null);
|
||||
Task<SpendingGroupDto> Create(SpendingGroupDto spendingGroup);
|
||||
Task<SpendingGroupDto?> Delete(SpendingGroupSearch search);
|
||||
|
9
back/Contracts/Services/IReportOffsetFromPlanService.cs
Normal file
9
back/Contracts/Services/IReportOffsetFromPlanService.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Contracts.SearchModels;
|
||||
using Contracts.ViewModels;
|
||||
|
||||
namespace Contracts.Services;
|
||||
|
||||
public interface IReportOffsetFromPlanService
|
||||
{
|
||||
public Task<SpendingGroupViewModel> GetReportData(SpendingPlanSearch search);
|
||||
}
|
8
back/Contracts/Services/IReportPeriodService.cs
Normal file
8
back/Contracts/Services/IReportPeriodService.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Contracts.ViewModels;
|
||||
|
||||
namespace Contracts.Services;
|
||||
|
||||
public interface IReportPeriodService
|
||||
{
|
||||
Task<IEnumerable<ChangeRecordViewModel>> GetReportData(DateTime from, DateTime to, Guid userId);
|
||||
}
|
62
back/Controllers/Controllers/ReportController.cs
Normal file
62
back/Controllers/Controllers/ReportController.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using Contracts.Services;
|
||||
using Contracts.ViewModels;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Services.Support.Exceptions;
|
||||
|
||||
namespace Controllers.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ReportController : ControllerBase
|
||||
{
|
||||
private readonly IReportPeriodService _reportPeriodService;
|
||||
private readonly IReportOffsetFromPlanService _reportOffsetFromPlanService;
|
||||
|
||||
public ReportController(IReportPeriodService reportPeriodService,
|
||||
IReportOffsetFromPlanService reportOffsetFromPlanService)
|
||||
{
|
||||
_reportPeriodService = reportPeriodService;
|
||||
_reportOffsetFromPlanService = reportOffsetFromPlanService;
|
||||
}
|
||||
|
||||
[HttpGet("period/{id}")]
|
||||
public async Task<ActionResult<IEnumerable<ChangeRecordViewModel>>> GetReportData(
|
||||
[FromQuery] DateTime from, [FromQuery] DateTime to, Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var periodData = await _reportPeriodService.GetReportData(from, to, id);
|
||||
return Ok(periodData);
|
||||
}
|
||||
catch (ReportDataNotFoundException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("plan/{id}")]
|
||||
public async Task<ActionResult<SpendingGroupViewModel>> GetReportOffsetFromPlan(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = await _reportOffsetFromPlanService.GetReportData(new() { Id = id });
|
||||
return Ok(offset);
|
||||
}
|
||||
catch (ReportDataNotFoundException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (EntityNotFoundException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using Contracts.Services;
|
||||
using Services.Domain;
|
||||
using Services.Reports;
|
||||
|
||||
namespace Controllers.Extensions;
|
||||
|
||||
@ -9,11 +10,10 @@ public static class AddDomainServicesExtension
|
||||
{
|
||||
services.AddTransient<IAuthService, AuthService>();
|
||||
services.AddTransient<IUserService, UserService>();
|
||||
|
||||
services.AddTransient<ISpendingGroupService, SpendingGroupService>();
|
||||
|
||||
services.AddTransient<IChangeRecordService, ChangeRecordService>();
|
||||
|
||||
services.AddTransient<ISpendingPlanService, SpendingPlanService>();
|
||||
|
||||
services.AddTransient<IReportPeriodService, ReportPeriodService>();
|
||||
}
|
||||
}
|
13
back/Controllers/Extensions/AddRepotServicesExt.cs
Normal file
13
back/Controllers/Extensions/AddRepotServicesExt.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using Contracts.Services;
|
||||
using Services.Reports;
|
||||
|
||||
namespace Controllers.Extensions;
|
||||
|
||||
public static class AddReportServicesExtension
|
||||
{
|
||||
public static void AddReportServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IReportPeriodService, ReportPeriodService>();
|
||||
services.AddTransient<IReportOffsetFromPlanService, ReportOffsetFromPlanService>();
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddDbConnectionService(builder.Configuration);
|
||||
builder.Services.AddRepos();
|
||||
builder.Services.AddDomainServices();
|
||||
builder.Services.AddReportServices();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
|
@ -46,6 +46,7 @@ public class ChangeRecordRepo : IChangeRecordRepo
|
||||
using var context = _factory.CreateDbContext();
|
||||
|
||||
var record = await context.ChangeRecords
|
||||
.Include(x => x.SpendingGroup)
|
||||
.FirstOrDefaultAsync(x => x.Id == search.Id);
|
||||
if (record == null)
|
||||
{
|
||||
|
@ -46,6 +46,7 @@ public class SpendingGroupRepo : ISpendingGroupRepo
|
||||
using var context = _factory.CreateDbContext();
|
||||
|
||||
var group = await context.SpendingGroups
|
||||
.AsNoTracking()
|
||||
.Include(x => x.ChangeRecords)
|
||||
.Include(x => x.SpendingPlans)
|
||||
.FirstOrDefaultAsync(x => x.Id == search.Id
|
||||
@ -56,11 +57,43 @@ public class SpendingGroupRepo : ISpendingGroupRepo
|
||||
return group?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<SpendingGroupDto?> GetByPlan(SpendingPlanSearch search)
|
||||
{
|
||||
using var context = _factory.CreateDbContext();
|
||||
var plan = await context.SpendingPlans
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == search.Id);
|
||||
|
||||
if (plan == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var group = await context.SpendingGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == plan.SpendingGroupId)
|
||||
.Include(x => x.ChangeRecords!
|
||||
// Выбираем из них только те, которые попадают в диапазон дат плана
|
||||
.Where(cg => cg.ChangedAt >= plan.StartAt && cg.ChangedAt <= plan.EndAt)
|
||||
// И сортируем их по дате
|
||||
.OrderBy(cg => cg.ChangedAt)
|
||||
)
|
||||
.FirstOrDefaultAsync();
|
||||
if (group == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
group.SpendingPlans = [plan];
|
||||
|
||||
return group.ToDto();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SpendingGroupDto>> GetList(SpendingGroupSearch? search = null)
|
||||
{
|
||||
using var context = _factory.CreateDbContext();
|
||||
|
||||
var query = context.SpendingGroups.AsQueryable();
|
||||
var query = context.SpendingGroups.AsNoTracking().AsQueryable();
|
||||
|
||||
if (search != null)
|
||||
{
|
||||
@ -69,10 +102,13 @@ public class SpendingGroupRepo : ISpendingGroupRepo
|
||||
query = query.Where(x => x.Id == search.Id);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search.Name) && search.UserId.HasValue)
|
||||
if (search.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Name.Contains(search.Name, StringComparison.OrdinalIgnoreCase)
|
||||
&& x.UserId == search.UserId);
|
||||
query = query.Where(x => x.UserId == search.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(search.Name))
|
||||
{
|
||||
query = query.Where(x => x.Name.Contains(search.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
56
back/Services.Tests/Reports/ReportOffsetServiceTests.cs
Normal file
56
back/Services.Tests/Reports/ReportOffsetServiceTests.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using Contracts.DTO;
|
||||
using Contracts.Repositories;
|
||||
using Contracts.SearchModels;
|
||||
using Contracts.ViewModels;
|
||||
using Moq;
|
||||
using Services.Reports;
|
||||
using Services.Support.Exceptions;
|
||||
|
||||
namespace Services.Tests.Reports;
|
||||
|
||||
public class ReportOffsetServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetReportData_WhenSpendingGroupNotFound_ThenThrowsEntityNotFoundException()
|
||||
{
|
||||
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
|
||||
spendingGroupRepoMock.Setup(repo => repo.GetByPlan(It.IsAny<SpendingPlanSearch>())).ReturnsAsync((SpendingGroupDto)null);
|
||||
var reportOffsetService = new ReportOffsetFromPlanService(spendingGroupRepoMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<EntityNotFoundException>(() => reportOffsetService.GetReportData(new()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReportData_WhenSpendingGroupHasNoChangeRecords_ThenThrowsReportDataNotFoundException()
|
||||
{
|
||||
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
|
||||
spendingGroupRepoMock.Setup(repo => repo.GetByPlan(It.IsAny<SpendingPlanSearch>())).ReturnsAsync(new SpendingGroupDto());
|
||||
var reportOffsetService = new ReportOffsetFromPlanService(spendingGroupRepoMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ReportDataNotFoundException>(() => reportOffsetService.GetReportData(new()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReportData_WhenSpendingGroupHasChangeRecords_ThenReturnsSpendingGroupViewModel()
|
||||
{
|
||||
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
|
||||
var spendingGroup = new SpendingGroupDto()
|
||||
{
|
||||
ChangeRecords =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChangedAt = DateTime.Now
|
||||
}
|
||||
]
|
||||
};
|
||||
spendingGroupRepoMock.Setup(repo => repo.GetByPlan(It.IsAny<SpendingPlanSearch>())).ReturnsAsync(spendingGroup);
|
||||
var reportOffsetService = new ReportOffsetFromPlanService(spendingGroupRepoMock.Object);
|
||||
|
||||
var result = await reportOffsetService.GetReportData(new());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<SpendingGroupViewModel>(result);
|
||||
}
|
||||
}
|
41
back/Services.Tests/Reports/ReportPeriodServiceTests.cs
Normal file
41
back/Services.Tests/Reports/ReportPeriodServiceTests.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using Contracts.DTO;
|
||||
using Contracts.Repositories;
|
||||
using Contracts.SearchModels;
|
||||
using Contracts.ViewModels;
|
||||
using Moq;
|
||||
using Services.Reports;
|
||||
using Services.Support.Exceptions;
|
||||
|
||||
namespace Services.Tests.Reports;
|
||||
|
||||
public class ReportPeriodServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetReportData_WhenChangeRecordsNotFound_ThenThrowsReportDataNotFoundException()
|
||||
{
|
||||
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
|
||||
changeRecordRepoMock.Setup(repo => repo.GetList(It.IsAny<ChangeRecordSearch>())).ReturnsAsync((List<ChangeRecordDto>)null);
|
||||
var reportPeriodService = new ReportPeriodService(changeRecordRepoMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ReportDataNotFoundException>(() => reportPeriodService.GetReportData(DateTime.MinValue, DateTime.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReportData_WhenChangeRecordsFound_ThenReturnsChangeRecordViewModels()
|
||||
{
|
||||
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
|
||||
var changeRecords = new List<ChangeRecordDto>()
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), ChangedAt = DateTime.Now },
|
||||
new() { Id = Guid.NewGuid(), ChangedAt = DateTime.Now }
|
||||
};
|
||||
changeRecordRepoMock.Setup(repo => repo.GetList(It.IsAny<ChangeRecordSearch>())).ReturnsAsync(changeRecords);
|
||||
var reportPeriodService = new ReportPeriodService(changeRecordRepoMock.Object);
|
||||
|
||||
var result = await reportPeriodService.GetReportData(DateTime.MinValue, DateTime.MaxValue);
|
||||
|
||||
changeRecordRepoMock.Verify(repo => repo.GetList(It.IsAny<ChangeRecordSearch>()), Times.Once);
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<List<ChangeRecordViewModel>>(result.ToList());
|
||||
}
|
||||
}
|
32
back/Services/Reports/ReportOffsetFromPlanService.cs
Normal file
32
back/Services/Reports/ReportOffsetFromPlanService.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Contracts.Mappers;
|
||||
using Contracts.Repositories;
|
||||
using Contracts.SearchModels;
|
||||
using Contracts.Services;
|
||||
using Contracts.ViewModels;
|
||||
using Services.Support.Exceptions;
|
||||
|
||||
namespace Services.Reports;
|
||||
|
||||
public class ReportOffsetFromPlanService : IReportOffsetFromPlanService
|
||||
{
|
||||
private readonly ISpendingGroupRepo _spendingGroupRepo;
|
||||
|
||||
public ReportOffsetFromPlanService(ISpendingGroupRepo spendingGroupRepo)
|
||||
{
|
||||
_spendingGroupRepo = spendingGroupRepo;
|
||||
}
|
||||
|
||||
public async Task<SpendingGroupViewModel> GetReportData(SpendingPlanSearch search)
|
||||
{
|
||||
var group = await _spendingGroupRepo.GetByPlan(search);
|
||||
if (group == null)
|
||||
{
|
||||
throw new EntityNotFoundException("Не удалось найти группу по такому плану");
|
||||
}
|
||||
if (!group.ChangeRecords.Any())
|
||||
{
|
||||
throw new ReportDataNotFoundException("Данные об изменении баланса отсутствуют");
|
||||
}
|
||||
return group.ToView();
|
||||
}
|
||||
}
|
29
back/Services/Reports/ReportPeriodService.cs
Normal file
29
back/Services/Reports/ReportPeriodService.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Contracts.Mappers;
|
||||
using Contracts.Repositories;
|
||||
using Contracts.SearchModels;
|
||||
using Contracts.Services;
|
||||
using Contracts.ViewModels;
|
||||
using Services.Support.Exceptions;
|
||||
|
||||
namespace Services.Reports;
|
||||
|
||||
public class ReportPeriodService : IReportPeriodService
|
||||
{
|
||||
private readonly IChangeRecordRepo _changeRecordRepo;
|
||||
|
||||
public ReportPeriodService(IChangeRecordRepo changeRecordRepo)
|
||||
{
|
||||
_changeRecordRepo = changeRecordRepo;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChangeRecordViewModel>> GetReportData(DateTime from, DateTime to, Guid userId)
|
||||
{
|
||||
var records = await _changeRecordRepo.GetList(new ChangeRecordSearch() { From = from, To = to, UserId = userId });
|
||||
|
||||
if (!records.Any())
|
||||
{
|
||||
throw new ReportDataNotFoundException("Нет данных за указанный период");
|
||||
}
|
||||
return records.Select(x => x.ToView()).ToList();
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
namespace Services.Support.Exceptions;
|
||||
|
||||
public class ReportDataNotFoundException : EntityNotFoundException
|
||||
{
|
||||
public ReportDataNotFoundException(string message)
|
||||
: base(message) { }
|
||||
public ReportDataNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
|
||||
}
|
@ -35,6 +35,8 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
7
front/components.d.ts
vendored
7
front/components.d.ts
vendored
@ -9,6 +9,7 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
@ -18,16 +19,22 @@ declare module 'vue' {
|
||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
|
||||
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
|
||||
ChangeRecordManager: typeof import('./src/components/support/ChangeRecordManager.vue')['default']
|
||||
GetPeriodReportForm: typeof import('./src/components/support/GetPeriodReportForm.vue')['default']
|
||||
Groups: typeof import('./src/components/pages/Groups.vue')['default']
|
||||
Header: typeof import('./src/components/main/Header.vue')['default']
|
||||
Home: typeof import('./src/components/pages/Home.vue')['default']
|
||||
Login: typeof import('./src/components/pages/Login.vue')['default']
|
||||
OffsetReport: typeof import('./src/components/pages/OffsetReport.vue')['default']
|
||||
PeriodReport: typeof import('./src/components/pages/PeriodReport.vue')['default']
|
||||
PlanManager: typeof import('./src/components/support/PlanManager.vue')['default']
|
||||
Plans: typeof import('./src/components/pages/Plans.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
174
front/package-lock.json
generated
174
front/package-lock.json
generated
@ -13,7 +13,8 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"pinia": "^2.2.8",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-charts": "^1.1.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
@ -1443,6 +1444,136 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
@ -1726,6 +1857,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@ -2308,6 +2448,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ramda": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
|
||||
"integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ramda"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@ -2895,6 +3045,28 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue3-charts": {
|
||||
"version": "1.1.33",
|
||||
"resolved": "https://registry.npmjs.org/vue3-charts/-/vue3-charts-1.1.33.tgz",
|
||||
"integrity": "sha512-gu2N/oORcAWLo3orfoKz5CRohZdmxQP7k2SZ8cgRsD9hFmYpekesE41EUPdGuZ5Y9gAo2LbGYW7fmIGbbPezDg==",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0",
|
||||
"d3-axis": "^3.0.0",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-shape": "^3.1.0",
|
||||
"ramda": "^0.28.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
|
@ -15,7 +15,8 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"pinia": "^2.2.8",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-charts": "^1.1.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
|
@ -3,6 +3,7 @@ import { inject } from 'vue';
|
||||
import { useUserStore } from '../../store';
|
||||
import { AuthService } from '../../core/services/auth-service';
|
||||
import router from '../../router';
|
||||
import { HomeOutlined, BlockOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const store = useUserStore();
|
||||
const authService = inject(AuthService.name) as AuthService;
|
||||
@ -19,8 +20,8 @@ function logout() {
|
||||
<div class="base-nav">
|
||||
<div>ДомБюдж</div>
|
||||
<nav>
|
||||
<RouterLink :to="{ name: 'home' }">Главная</RouterLink>
|
||||
<RouterLink :to="{ name: 'groups' }">Группы расходов</RouterLink>
|
||||
<RouterLink :to="{ name: 'home' }"><HomeOutlined /> Главная</RouterLink>
|
||||
<RouterLink :to="{ name: 'groups' }"><BlockOutlined /> Группы расходов</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div v-if="!store.user.id">
|
||||
|
@ -3,7 +3,7 @@ import { useAsyncState } from '@vueuse/core';
|
||||
import { inject } from 'vue';
|
||||
import { GroupService } from '../../core/services/group-service';
|
||||
import SpendingGroupManager from '../support/SpendingGroupManager.vue';
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { DeleteOutlined, CalendarOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const groupService = inject(GroupService.name) as GroupService;
|
||||
|
||||
@ -43,12 +43,15 @@ const onDelete = (key: string) => {
|
||||
|
||||
<template>
|
||||
<div class="base-page">
|
||||
<h1>Группы расходов</h1>
|
||||
<a-typography-title>Группы расходов</a-typography-title>
|
||||
<SpendingGroupManager :refreshData="refreshData" />
|
||||
<a-table :dataSource="state" :columns="columns" v-if="isReady">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'plans'">
|
||||
<RouterLink :to="{ name: 'plans', params: { groupId: record.id } }" >Планы</RouterLink>
|
||||
<RouterLink :to="{ name: 'plans', params: { groupId: record.id } }"
|
||||
>
|
||||
<CalendarOutlined /> Планы
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'operation'">
|
||||
<a-popconfirm
|
||||
|
@ -4,6 +4,7 @@ import ChangeRecordMenu from '../support/ChangeRecordManager.vue';
|
||||
import { ChangeRecordService } from '../../core/services/change-record-service';
|
||||
import { inject } from 'vue';
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import GetPeriodReportForm from '../support/GetPeriodReportForm.vue';
|
||||
|
||||
const changeRecordService = inject(ChangeRecordService.name) as ChangeRecordService;
|
||||
|
||||
@ -48,8 +49,11 @@ const onDelete = (key: string) => {
|
||||
|
||||
<template>
|
||||
<div class="base-page">
|
||||
<h1>История изменений баланса</h1>
|
||||
<a-typography-title>История изменений баланса</a-typography-title>
|
||||
<a-divider />
|
||||
<ChangeRecordMenu :refreshData="refreshData" />
|
||||
<GetPeriodReportForm />
|
||||
<a-divider />
|
||||
<a-table :dataSource="state" :columns="columns" v-if="isReady" >
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'operation'">
|
||||
|
100
front/src/components/pages/OffsetReport.vue
Normal file
100
front/src/components/pages/OffsetReport.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<style scoped>
|
||||
</style>
|
||||
<template>
|
||||
<div class="base-page">
|
||||
<a-typography-title>Отчет о смещении затрат группы относительно плана</a-typography-title>
|
||||
<div v-if="isReady">
|
||||
<a-typography-title :level="4">
|
||||
Для {{ reportData.groupName }} с {{ reportData.startAt }} по {{ reportData.endAt }}. План: {{ reportData.planSum }}
|
||||
</a-typography-title>
|
||||
<Responsive class="w-full">
|
||||
<template #main="{ width }">
|
||||
<Chart
|
||||
:direction="'horizontal'"
|
||||
:data="reportData.data"
|
||||
:size="{ width, height: 400 }"
|
||||
:margin="{
|
||||
left: 0,
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 0
|
||||
}"
|
||||
:axis="{
|
||||
primary: {
|
||||
domain: ['dataMin', 'dataMax'],
|
||||
type: 'band'
|
||||
},
|
||||
secondary: {
|
||||
domain: ['dataMin', 'dataMax + 20'],
|
||||
type: 'linear'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #layers>
|
||||
<Grid strokeDasharray="2,2" />
|
||||
<Area :dataKeys="['label', 'data']"
|
||||
type="monotone" :areaStyle="{ fill: 'url(#grad)' }"
|
||||
/>
|
||||
<Line
|
||||
:dataKeys="['label', 'data']"
|
||||
type="monotone"
|
||||
:lineStyle="{
|
||||
stroke: '#9f7aea'}"
|
||||
/>
|
||||
<Marker :value="reportData.planSum" label="План" color="#e76f51" :strokeWidth="2" strokeDasharray="6 6" />
|
||||
<defs>
|
||||
<linearGradient id="grad" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stop-color="#be90ff" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="white" stop-opacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</template>
|
||||
<template #widgets>
|
||||
<Tooltip
|
||||
:config="{
|
||||
label: { label: 'Дата' },
|
||||
data: { label: 'Затраты' },
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</Chart>
|
||||
</template>
|
||||
</Responsive>
|
||||
<div v-if="reportData.total > reportData.planSum">
|
||||
<a-typography-text type="danger" strong>
|
||||
<WarningOutlined /> Затраты вышли за указанный план! Итоговая разница: {{ reportData.total - reportData.planSum }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a-typography-text type="success">
|
||||
<CheckOutlined /> Затраты не вышли за указанный план! Итоговая разница: {{ reportData.planSum - reportData.total }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<br/>
|
||||
<a-table
|
||||
:dataSource="reportData.data"
|
||||
:columns="[
|
||||
{ title: 'Дата', dataIndex: 'label', key: 'label' },
|
||||
{ title: 'Сумма', dataIndex: 'data', key: 'data' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { OffsetFromPlanReportData, ReportService } from '../../core/services/report-service';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
import { Chart, Grid, Tooltip, Line, Marker, Responsive, Area } from 'vue3-charts';
|
||||
import { CheckOutlined, WarningOutlined } from '@ant-design/icons-vue';
|
||||
let reportService = inject(ReportService.name) as ReportService;
|
||||
let planId = useRoute().params.planId as string;
|
||||
|
||||
const { state : reportData, isReady } = useAsyncState(() => reportService.getOffsetFromPlanData(planId), {} as OffsetFromPlanReportData);
|
||||
|
||||
|
||||
</script>
|
82
front/src/components/pages/PeriodReport.vue
Normal file
82
front/src/components/pages/PeriodReport.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<style scoped>
|
||||
</style>
|
||||
<template>
|
||||
<div class="base-page">
|
||||
<a-typography-title>Отчет за период</a-typography-title>
|
||||
<div v-if="isReady">
|
||||
<a-typography-title :level="4">
|
||||
с {{ dayjs(startAt).format("DD.MM.YYYY") }} по {{ dayjs(endAt).format("DD.MM.YYYY") }}
|
||||
</a-typography-title>
|
||||
<Responsive class="w-full">
|
||||
<template #main="{ width }">
|
||||
<Chart
|
||||
direction="circular"
|
||||
:data="reportData.data"
|
||||
:size="{ width, height: 400 }"
|
||||
:margin="{
|
||||
left: Math.round((width - 360)/2),
|
||||
top: 20,
|
||||
right: 0,
|
||||
bottom: 20
|
||||
}"
|
||||
:axis="{
|
||||
primary: {
|
||||
domain: ['dataMin', 'dataMax'],
|
||||
type: 'band',
|
||||
hide: true
|
||||
},
|
||||
secondary: {
|
||||
domain: ['dataMin', 'dataMax'],
|
||||
type: 'linear',
|
||||
hide: true
|
||||
}
|
||||
}"
|
||||
:config="{ controlHover: false }"
|
||||
>
|
||||
<template #layers>
|
||||
<Pie
|
||||
:dataKeys="['label', 'data']"
|
||||
:pie-style="{ innerRadius: 100, padAngle: 0.05 }" />
|
||||
</template>
|
||||
<template #widgets>
|
||||
<Tooltip
|
||||
:config="{
|
||||
label: { label: 'Группа' },
|
||||
data: { label: 'Затраты' },
|
||||
}"
|
||||
hideLine
|
||||
/>
|
||||
</template>
|
||||
</Chart>
|
||||
</template>
|
||||
</Responsive>
|
||||
<a-typography-title :level="4">
|
||||
<MonitorOutlined /> Сумма всех затрат за этот период: {{ reportData.total }}
|
||||
</a-typography-title>
|
||||
<br/>
|
||||
<a-table
|
||||
:dataSource="reportData.data"
|
||||
:columns="[
|
||||
{ title: 'Группа затрат', dataIndex: 'label', key: 'label' },
|
||||
{ title: 'Сумма', dataIndex: 'data', key: 'data' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { PeriodReportData, ReportService } from '../../core/services/report-service';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
import { Pie, Chart, Tooltip, Responsive } from 'vue3-charts';
|
||||
import { MonitorOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs from 'dayjs';
|
||||
let reportService = inject(ReportService.name) as ReportService;
|
||||
let { startAt, endAt } = useRoute().query as { startAt: string, endAt: string };
|
||||
const { state : reportData, isReady } = useAsyncState(() => reportService.getPeriodData(dayjs(startAt), dayjs(endAt)), {} as PeriodReportData);
|
||||
|
||||
</script>
|
@ -4,7 +4,7 @@ import { inject } from 'vue';
|
||||
import { PlanService } from '../../core/services/plans-service';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PlanManager from '../support/PlanManager.vue';
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { DeleteOutlined, BarChartOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const planService = inject(PlanService.name) as PlanService;
|
||||
const groupId = useRoute().params.groupId as string;
|
||||
@ -53,7 +53,7 @@ const onDelete = (key: string) => {
|
||||
|
||||
<template>
|
||||
<div class="base-page">
|
||||
<h1>Планы группы</h1>
|
||||
<a-typography-title>Планы группы</a-typography-title>
|
||||
<PlanManager :groupId="groupId" :refreshData="refreshData"/>
|
||||
<a-table :dataSource="state" :columns="columns" v-if="isReady">
|
||||
<template #bodyCell="{ column, record }">
|
||||
@ -65,6 +65,15 @@ const onDelete = (key: string) => {
|
||||
>
|
||||
<a><DeleteOutlined /> Удалить</a>
|
||||
</a-popconfirm>
|
||||
<br/>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'offsetReport',
|
||||
params: { planId: record.id }
|
||||
}"
|
||||
>
|
||||
<BarChartOutlined /> Отчет
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
36
front/src/components/support/GetPeriodReportForm.vue
Normal file
36
front/src/components/support/GetPeriodReportForm.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
|
||||
<a-space>
|
||||
|
||||
<a-range-picker v-model:value="dateRange" />
|
||||
<a-button type="primary"
|
||||
:disabled="!dateRange"
|
||||
@click="onFinish"
|
||||
:loading="isClicked"
|
||||
>
|
||||
<PieChartOutlined />Получить отчет
|
||||
</a-button>
|
||||
|
||||
</a-space>
|
||||
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { ref } from 'vue';
|
||||
import { PieChartOutlined } from '@ant-design/icons-vue';
|
||||
import router from '../../router';
|
||||
|
||||
const dateRange = ref<[Dayjs, Dayjs]>();
|
||||
const isClicked = ref(false);
|
||||
const onFinish = () => {
|
||||
isClicked.value = true;
|
||||
console.log(dateRange.value);
|
||||
router.push({
|
||||
name: 'periodReport',
|
||||
query: {
|
||||
startAt: dateRange.value![0].toISOString(),
|
||||
endAt: dateRange.value![1].toISOString()
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
@ -165,6 +165,46 @@ export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Report
|
||||
* @name ReportPeriodList
|
||||
* @request GET:/api/Report/period/{id}
|
||||
* @response `200` `(ChangeRecordViewModel)[]` Success
|
||||
*/
|
||||
reportPeriodList = (
|
||||
id: string,
|
||||
query?: {
|
||||
/** @format date-time */
|
||||
from?: string;
|
||||
/** @format date-time */
|
||||
to?: string;
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ChangeRecordViewModel[], any>({
|
||||
path: `/api/Report/period/${id}`,
|
||||
method: "GET",
|
||||
query: query,
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags Report
|
||||
* @name ReportPlanDetail
|
||||
* @request GET:/api/Report/plan/{id}
|
||||
* @response `200` `SpendingGroupViewModel` Success
|
||||
*/
|
||||
reportPlanDetail = (id: string, params: RequestParams = {}) =>
|
||||
this.request<SpendingGroupViewModel, any>({
|
||||
path: `/api/Report/plan/${id}`,
|
||||
method: "GET",
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
|
@ -28,8 +28,7 @@ export interface ChangeRecordViewModel {
|
||||
id?: string;
|
||||
/** @format double */
|
||||
sum?: number;
|
||||
/** @format date-time */
|
||||
changedAt?: string;
|
||||
changedAt?: string | null;
|
||||
spendingGroupName?: string | null;
|
||||
}
|
||||
|
||||
@ -67,10 +66,8 @@ export interface SpendingPlanDto {
|
||||
export interface SpendingPlanViewModel {
|
||||
/** @format uuid */
|
||||
id?: string;
|
||||
/** @format date-time */
|
||||
startAt?: string;
|
||||
/** @format date-time */
|
||||
endAt?: string;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
/** @format double */
|
||||
sum?: number;
|
||||
}
|
||||
|
104
front/src/core/services/report-service.ts
Normal file
104
front/src/core/services/report-service.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Dayjs } from "dayjs";
|
||||
import { Api } from "../api/Api";
|
||||
import { ChangeRecordViewModel, SpendingGroupViewModel } from "../api/data-contracts";
|
||||
import { useUserStore } from "../../store";
|
||||
|
||||
export type ReportData = {
|
||||
label: string;
|
||||
data: number | string;
|
||||
};
|
||||
export interface OffsetFromPlanReportData {
|
||||
groupName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
planSum: number;
|
||||
total: number,
|
||||
data: ReportData[];
|
||||
}
|
||||
|
||||
export interface PeriodReportData {
|
||||
data: ReportData[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export class ReportService {
|
||||
private readonly _api: Api
|
||||
constructor(api: Api) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public async getOffsetFromPlanData(Id: string): Promise<OffsetFromPlanReportData> {
|
||||
console.log(Id);
|
||||
let res = await this._api.reportPlanDetail(Id);
|
||||
console.log(res);
|
||||
let reportData = getOffsetFromPlanData(res.data);
|
||||
if (!reportData) throw new Error("Cannot get report data");
|
||||
return reportData;
|
||||
}
|
||||
|
||||
public async getPeriodData(from: Dayjs, to: Dayjs): Promise<PeriodReportData> {
|
||||
let userId = useUserStore().user.id;
|
||||
if (!userId) throw new Error("Id пользователя не найден");
|
||||
let res = await this._api.reportPeriodList(userId, {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString()
|
||||
});
|
||||
console.log(res);
|
||||
let reportData = getPeriodData(res.data);
|
||||
if (!reportData) throw new Error("Cannot get report data");
|
||||
console.log(reportData);
|
||||
return reportData;
|
||||
}
|
||||
}
|
||||
|
||||
function getOffsetFromPlanData(group: SpendingGroupViewModel): OffsetFromPlanReportData | null {
|
||||
let reportData : ReportData[] = [];
|
||||
getReportDataFromGroup(group, reportData,
|
||||
(cr) => reportData.push({
|
||||
label: cr.changedAt!,
|
||||
data: Math.abs(cr.sum!)
|
||||
})
|
||||
);
|
||||
if (reportData.length == 0) return null;
|
||||
return {
|
||||
groupName: group.name!,
|
||||
startAt: group.spendingPlans![0].startAt!,
|
||||
endAt: group.spendingPlans![0].endAt!,
|
||||
planSum: group.spendingPlans![0].sum!,
|
||||
total: reportData.map(x => x.data as number).reduce((a, b) => a + b, 0),
|
||||
data: reportData
|
||||
};
|
||||
}
|
||||
|
||||
function getPeriodData(records: ChangeRecordViewModel[]): PeriodReportData | null {
|
||||
let reportData : ReportData[] = [];
|
||||
getReportDataFromRecords(records, reportData,
|
||||
(cr) => reportData.push({
|
||||
label: cr.spendingGroupName!,
|
||||
data: Math.abs(cr.sum!)
|
||||
}),
|
||||
(x, y) => x.label == y.spendingGroupName
|
||||
);
|
||||
return {
|
||||
data: reportData,
|
||||
total: reportData.map(x => x.data as number).reduce((a, b) => a + b, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function getReportDataFromGroup(group: SpendingGroupViewModel, reportData: ReportData[], callbackPush: (changeRecor: ChangeRecordViewModel) => void) {
|
||||
if (!group.changeRecords) return;
|
||||
getReportDataFromRecords(group.changeRecords, reportData, callbackPush, (x, y) => x.label == y.changedAt);
|
||||
}
|
||||
|
||||
function getReportDataFromRecords(records: ChangeRecordViewModel[], reportData: ReportData[],
|
||||
callbackPush: (changeRecor: ChangeRecordViewModel) => void,
|
||||
callbackFind: (x: ReportData, y: ChangeRecordViewModel) => boolean) {
|
||||
records.forEach((cr) => {
|
||||
if (!reportData.find(x => callbackFind(x, cr))) {
|
||||
callbackPush(cr);
|
||||
}
|
||||
else {
|
||||
(reportData.find(x => callbackFind(x, cr))!.data as number) += Math.abs(cr.sum!);
|
||||
}
|
||||
})
|
||||
}
|
@ -8,6 +8,7 @@ import { AuthService } from './core/services/auth-service'
|
||||
import { ChangeRecordService } from './core/services/change-record-service'
|
||||
import { GroupService } from './core/services/group-service'
|
||||
import { PlanService } from './core/services/plans-service'
|
||||
import { ReportService } from './core/services/report-service'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@ -20,5 +21,6 @@ app.provide(AuthService.name, new AuthService(api));
|
||||
app.provide(ChangeRecordService.name, new ChangeRecordService(api));
|
||||
app.provide(GroupService.name, new GroupService(api));
|
||||
app.provide(PlanService.name, new PlanService(api));
|
||||
app.provide(ReportService.name, new ReportService(api));
|
||||
|
||||
app.mount('#app')
|
||||
|
@ -26,11 +26,21 @@ const router = createRouter({
|
||||
name: 'groups',
|
||||
component: () => import('./components/pages/Groups.vue'),
|
||||
},
|
||||
{
|
||||
path: '/groups/report/period',
|
||||
name: 'periodReport',
|
||||
component: () => import('./components/pages/PeriodReport.vue'),
|
||||
},
|
||||
{
|
||||
path: '/plans/:groupId',
|
||||
name: 'plans',
|
||||
component: () => import('./components/pages/Plans.vue'),
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/plans/report/offset/:planId',
|
||||
name: 'offsetReport',
|
||||
component: () => import('./components/pages/OffsetReport.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user