0.2.0 #4

Merged
mfnefd merged 15 commits from dev into main 2024-12-11 04:42:35 +04:00
61 changed files with 4492 additions and 30 deletions
Showing only changes of commit 2e861c19a4 - Show all commits

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

5
.env Normal file
View File

@ -0,0 +1,5 @@
POSTGRES_DB="dombudg"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
CONNECTION_STRING="Host=database:5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD};"

39
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/back/Controllers/bin/Debug/net8.0/Controllers.dll",
"args": [],
"cwd": "${workspaceFolder}/back/Controllers",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
},
{
"name": "Docker .NET Launch",
"type": "docker",
"request": "launch",
"preLaunchTask": "docker-run: debug",
"netCore": {
"appProject": "${workspaceFolder}/back/Controllers/Controllers.csproj"
}
}
]
}

101
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,101 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/back/Api.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/back/Api.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/back/Api.sln"
],
"problemMatcher": "$msCompile"
},
{
"type": "docker-build",
"label": "docker-build: debug",
"dependsOn": [
"build"
],
"dockerBuild": {
"tag": "dombudg:dev",
"target": "base",
"dockerfile": "${workspaceFolder}/back/Controllers/Dockerfile",
"context": "${workspaceFolder}",
"pull": true
},
"netCore": {
"appProject": "${workspaceFolder}/back/Controllers/Controllers.csproj"
}
},
{
"type": "docker-build",
"label": "docker-build: release",
"dependsOn": [
"build"
],
"dockerBuild": {
"tag": "dombudg:latest",
"dockerfile": "${workspaceFolder}/back/Controllers/Dockerfile",
"context": "${workspaceFolder}",
"platform": {
"os": "linux",
"architecture": "amd64"
},
"pull": true
},
"netCore": {
"appProject": "${workspaceFolder}/back/Controllers/Controllers.csproj"
}
},
{
"type": "docker-run",
"label": "docker-run: debug",
"dependsOn": [
"docker-build: debug"
],
"dockerRun": {},
"netCore": {
"appProject": "${workspaceFolder}/back/Controllers/Controllers.csproj",
"enableDebugging": true
}
},
{
"type": "docker-run",
"label": "docker-run: release",
"dependsOn": [
"docker-build: release"
],
"dockerRun": {},
"netCore": {
"appProject": "${workspaceFolder}/back/Controllers/Controllers.csproj"
}
}
]
}

View File

@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Contracts\Cont
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{A35121D4-7D41-4266-8DA4-87135E8ABF89}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Services.Tests", "Services.Tests\Services.Tests.csproj", "{F0BDEEB0-2850-4733-B196-DBC677B35E47}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -36,5 +38,9 @@ Global
{A35121D4-7D41-4266-8DA4-87135E8ABF89}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A35121D4-7D41-4266-8DA4-87135E8ABF89}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A35121D4-7D41-4266-8DA4-87135E8ABF89}.Release|Any CPU.Build.0 = Release|Any CPU
{F0BDEEB0-2850-4733-B196-DBC677B35E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0BDEEB0-2850-4733-B196-DBC677B35E47}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0BDEEB0-2850-4733-B196-DBC677B35E47}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0BDEEB0-2850-4733-B196-DBC677B35E47}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -5,7 +5,7 @@ public class ChangeRecordDto
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? SpendingGroupId { get; set; }
public string SpendingGroupName { get; set; } = string.Empty;
public string? SpendingGroupName { get; set; }
public decimal Sum { get; set; }
public DateTime ChangedAt { get; set; }
}

View File

@ -1,6 +1,6 @@
namespace Contracts.DTO;
public class UserLoginDTO
public class UserLoginDto
{
public string Name { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;

View File

@ -9,8 +9,8 @@ public static class ChangeRecordMapper
=> new()
{
Id = dto.Id,
Sum = dto.Sum,
ChangedAt = dto.ChangedAt,
SpendingGroupName = dto.SpendingGroupName
Sum = dto.Sum,
ChangedAt = dto.ChangedAt.ToString("dd.MM.yyyy"),
SpendingGroupName = dto.SpendingGroupName ?? string.Empty
};
}

View File

@ -9,8 +9,8 @@ public static class SpendingPlanMapper
=> new()
{
Id = dto.Id,
StartAt = dto.StartAt,
EndAt = dto.EndAt,
StartAt = dto.StartAt.ToString("dd.MM.yyyy"),
EndAt = dto.EndAt.ToString("dd.MM.yyyy"),
Sum = dto.Sum
};
}

View File

@ -6,4 +6,5 @@ public class ChangeRecordSearch
public Guid? SpendingGroupId { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public Guid? UserId { get; set; }
}

View File

@ -4,4 +4,5 @@ public class SpendingGroupSearch
{
public Guid? Id { get; set; }
public string? Name { get; set; }
public Guid? UserId { get; set; }
}

View File

@ -6,6 +6,6 @@ namespace Contracts.Services;
public interface IAuthService
{
public Task<UserViewModel> Login(UserLoginDTO loginData);
public Task<UserViewModel> Login(UserLoginDto loginData);
public Task<UserViewModel> Register(UserDto user);
}

View File

@ -4,6 +4,6 @@ public class ChangeRecordViewModel
{
public Guid Id { get; set; }
public decimal Sum { get; set; }
public DateTime ChangedAt { get; set; }
public string ChangedAt { get; set; } = null!;
public string SpendingGroupName { get; set; } = string.Empty;
}

View File

@ -3,7 +3,7 @@ namespace Contracts.ViewModels;
public class SpendingPlanViewModel
{
public Guid Id { get; set; }
public DateTime StartAt { get; set; }
public DateTime EndAt { get; set; }
public string StartAt { get; set; } = null!;
public string EndAt { get; set; } = null!;
public decimal Sum { get; set; }
}

View File

@ -18,7 +18,7 @@ public class AuthController : ControllerBase
}
[HttpPost]
public async Task<ActionResult<UserViewModel>> Login([FromBody] UserLoginDTO loginData)
public async Task<ActionResult<UserViewModel>> Login([FromBody] UserLoginDto loginData)
{
try
{

View File

@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 5125
ENV ASPNETCORE_URLS=http://+:5125
USER app
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG configuration=Release
WORKDIR /src
COPY ["back/Controllers/Controllers.csproj", "back/Controllers/"]
RUN dotnet restore "back/Controllers/Controllers.csproj"
COPY . .
WORKDIR "/src/back/Controllers"
RUN dotnet build "Controllers.csproj" -c $configuration -o /app/build
FROM build AS publish
ARG configuration=Release
RUN dotnet publish "Controllers.csproj" -c $configuration -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Controllers.dll"]

View File

@ -9,6 +9,7 @@ public static class DatabaseSetupExtension
{
var connectionString = config.GetConnectionString("DefaultConnection")
?? throw new ArgumentException("Нет строки подключения");
Console.WriteLine("Connection string: " + connectionString);
services.AddDbContext<DatabaseContext>(options => options.UseNpgsql(connectionString));
services.AddSingleton<IDbContextFactory<DatabaseContext>, DbContextFactory>();
}

View File

@ -21,9 +21,10 @@ if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
}
app.MigrateDb();
app.UseCors(builder => builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
app.UseHttpsRedirection();
app.UseAuthorization();

View File

@ -71,6 +71,10 @@ public class ChangeRecordRepo : IChangeRecordRepo
{
query = query.Where(x => x.ChangedAt >= search.From && x.ChangedAt <= search.To);
}
if (search.UserId.HasValue)
{
query = query.Where(x => x.UserId == search.UserId);
}
}
return await query.Include(x => x.SpendingGroup).Select(x => x.ToDto()).ToListAsync();
}

View File

@ -50,7 +50,9 @@ public class SpendingGroupRepo : ISpendingGroupRepo
.Include(x => x.ChangeRecords)
.Include(x => x.SpendingPlans)
.FirstOrDefaultAsync(x => x.Id == search.Id
|| x.Name == search.Name);
|| (!string.IsNullOrWhiteSpace(search.Name)
&& x.Name == search.Name
&& x.UserId == search.UserId));
return group?.ToDto();
}
@ -100,9 +102,10 @@ public class SpendingGroupRepo : ISpendingGroupRepo
query = query.Where(x => x.Id == search.Id);
}
if (!string.IsNullOrWhiteSpace(search.Name))
if (!string.IsNullOrWhiteSpace(search.Name) && search.UserId.HasValue)
{
query = query.Where(x => x.Name.Contains(search.Name, StringComparison.OrdinalIgnoreCase));
query = query.Where(x => x.Name.Contains(search.Name, StringComparison.OrdinalIgnoreCase)
&& x.UserId == search.UserId);
}
}

View File

@ -13,7 +13,7 @@ public static class ChangeRecordMapper
ChangedAt = changeRecord.ChangedAt,
SpendingGroupId = changeRecord.SpendingGroupId,
UserId = changeRecord.UserId,
SpendingGroupName = changeRecord.SpendingGroup?.Name ?? string.Empty
SpendingGroupName = changeRecord.SpendingGroup?.Name
};
public static ChangeRecord ToModel(this ChangeRecordDto changeRecord)

View File

@ -0,0 +1,83 @@
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.ViewModels;
using Moq;
using Services.Domain;
using Services.Support.Exceptions;
namespace Services.Tests.Domain;
public class AuthServiceTests
{
[Fact]
public void Register_WhenUserExists_ThrowsAlreadyExistsException()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>()))
.ReturnsAsync(new UserDto());
var authService = new AuthService(userRepoMock.Object);
var user = new UserDto { Name = "John Doe", Password = "password" };
Assert.ThrowsAsync<AlreadyExistsException>(() => authService.Register(user));
}
[Fact]
public void Register_WhenUserDoesNotExist_ThenCreateUser_ReturnsUserViewModel()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>())).ReturnsAsync((UserDto)null);
userRepoMock.Setup(repo => repo.Create(It.IsAny<UserDto>())).ReturnsAsync(new UserDto());
var authService = new AuthService(userRepoMock.Object);
var user = new UserDto { Name = "John Doe", Password = "password" };
var result = authService.Register(user);
userRepoMock.Verify(repo => repo.Create(It.IsAny<UserDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<Task<UserViewModel>>(result);
}
[Fact]
public void Login_WhenUserDoesNotExist_ThrowsUserNotFoundException()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>())).ReturnsAsync((UserDto)null);
var authService = new AuthService(userRepoMock.Object);
var user = new UserLoginDto { Name = "John Doe", Password = "password" };
Assert.ThrowsAsync<UserNotFoundException>(() => authService.Login(user));
}
[Fact]
public void Login_WhenUserExists_ReturnsUserViewModel()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>())).ReturnsAsync(new UserDto());
var authService = new AuthService(userRepoMock.Object);
var user = new UserLoginDto { Name = "John Doe", Password = "password" };
var result = authService.Login(user);
userRepoMock.Verify(repo => repo.Get(It.IsAny<UserSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<Task<UserViewModel>>(result);
}
[Fact]
public void Login_WhenWrongLoginData_ThrowsArgumentException()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>())).ReturnsAsync((UserDto)null);
var authService = new AuthService(userRepoMock.Object);
UserLoginDto user1 = null;
UserLoginDto user2 = new() { Name = "", Password = "password" };
UserLoginDto user3 = new() { Name = "John Doe", Password = "" };
UserLoginDto user4 = new() { Name = "", Password = "" };
Assert.ThrowsAsync<ArgumentException>(() => authService.Login(user1));
Assert.ThrowsAsync<ArgumentException>(() => authService.Login(user2));
Assert.ThrowsAsync<ArgumentException>(() => authService.Login(user3));
Assert.ThrowsAsync<ArgumentException>(() => authService.Login(user4));
}
}

View File

@ -0,0 +1,138 @@
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.ViewModels;
using Moq;
using Services.Domain;
using Services.Support.Exceptions;
using Services.Tests.Support.Fakes.Database;
using Services.Tests.Support.Fakes.Repositories;
namespace Services.Tests.Domain;
public class ChangeRecordServiceTests
{
[Theory]
[InlineData(50)]
[InlineData(-50)]
public async Task Create_WhenUserExists_ThenChangeBalance_ReturnsChangeRecordViewModel(int recordSum, int userBalance = 100)
{
var userRepoFake = new UserRepoFake();
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
changeRecordRepoMock.Setup(repo => repo.Create(It.IsAny<ChangeRecordDto>())).ReturnsAsync(new ChangeRecordDto());
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoFake);
// Add user
var user = new UserDto() { Id = Guid.NewGuid(), Balance = userBalance };
await userRepoFake.Create(user);
var changeRecord = new ChangeRecordDto { UserId = user.Id, Sum = recordSum, SpendingGroupId = Guid.NewGuid() };
var result = await changeRecordService.Create(changeRecord);
changeRecordRepoMock.Verify(repo => repo.Create(It.IsAny<ChangeRecordDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<ChangeRecordViewModel>(result);
var existedUser = await userRepoFake.Get(new UserSearch { Id = user.Id });
Assert.Equal(existedUser.Balance, userBalance + recordSum);
DbFake.ClearDb();
}
[Theory]
[InlineData(50)]
[InlineData(-50)]
public async Task Delete_WhenUserExists_ThenChangeBalance_ReturnsChangeRecordViewModel(int recordSum, int userBalance = 100)
{
var user = new UserDto() { Id = Guid.NewGuid(), Balance = userBalance };
var changeRecord = new ChangeRecordDto { UserId = user.Id, Sum = recordSum, SpendingGroupId = Guid.NewGuid() };
var userRepoFake = new UserRepoFake();
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
changeRecordRepoMock.Setup(repo => repo.Delete(It.IsAny<ChangeRecordSearch>())).ReturnsAsync(changeRecord);
changeRecordRepoMock.Setup(repo => repo.Create(It.IsAny<ChangeRecordDto>())).ReturnsAsync(new ChangeRecordDto());
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoFake);
// Add user and record
await userRepoFake.Create(user);
await changeRecordService.Create(changeRecord);
var result = await changeRecordService.Delete(new ChangeRecordSearch { Id = changeRecord.Id });
changeRecordRepoMock.Verify(repo => repo.Delete(It.IsAny<ChangeRecordSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<ChangeRecordViewModel>(result);
var existedUser = await userRepoFake.Get(new UserSearch { Id = user.Id });
Assert.Equal(existedUser.Balance, userBalance);
DbFake.ClearDb();
}
[Fact]
public void Delete_WhenRecordNotFound_ThenThrowsEntityNotFoundException()
{
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
var userRepoMock = new Mock<IUserRepo>();
changeRecordRepoMock.Setup(repo => repo.Delete(It.IsAny<ChangeRecordSearch>())).ReturnsAsync((ChangeRecordDto)null);
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoMock.Object);
Assert.ThrowsAsync<EntityNotFoundException>(() => changeRecordService.Delete(new()));
}
[Fact]
public async Task GetList_ReturnsChangeRecordViewModels()
{
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
var userRepoMock = new Mock<IUserRepo>();
changeRecordRepoMock.Setup(repo => repo.GetList(It.IsAny<ChangeRecordSearch>())).ReturnsAsync(GetAllChangeRecords());
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoMock.Object);
var result = await changeRecordService.GetList();
changeRecordRepoMock.Verify(repo => repo.GetList(It.IsAny<ChangeRecordSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<List<ChangeRecordViewModel>>(result.ToList());
}
[Theory]
[InlineData(50, 25)]
[InlineData(-50, 25)]
public async Task Update_WhenUserExists_ThenChangeBalance_ReturnsChangeRecordViewModel(int recordSum, int recorNewSum, int userBalance = 100)
{
var user = new UserDto() { Id = Guid.NewGuid(), Balance = userBalance };
var changeRecord = new ChangeRecordDto { UserId = user.Id, Sum = recordSum, SpendingGroupId = Guid.NewGuid() };
var userRepoFake = new UserRepoFake();
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
changeRecordRepoMock.Setup(repo => repo.Update(It.IsAny<ChangeRecordDto>())).ReturnsAsync(changeRecord);
changeRecordRepoMock.Setup(repo => repo.Create(It.IsAny<ChangeRecordDto>())).ReturnsAsync(new ChangeRecordDto());
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoFake);
// Add user and record
await userRepoFake.Create(user);
await changeRecordService.Create(changeRecord);
var newChangeRecord = new ChangeRecordDto { Id = Guid.NewGuid(), UserId = user.Id, Sum = recorNewSum, SpendingGroupId = Guid.NewGuid() };
var result = await changeRecordService.Update(newChangeRecord);
changeRecordRepoMock.Verify(repo => repo.Update(It.IsAny<ChangeRecordDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<ChangeRecordViewModel>(result);
var existedUser = await userRepoFake.Get(new UserSearch { Id = user.Id });
Assert.Equal(existedUser.Balance, userBalance + recorNewSum);
DbFake.ClearDb();
}
[Fact]
public void Update_WhenRecordNotExists_ThenThrowsEntityNotFoundException()
{
var changeRecordRepoMock = new Mock<IChangeRecordRepo>();
changeRecordRepoMock.Setup(repo => repo.Update(It.IsAny<ChangeRecordDto>())).ReturnsAsync((ChangeRecordDto?)null);
var userRepoMock = new Mock<IUserRepo>();
var changeRecordService = new ChangeRecordService(changeRecordRepoMock.Object, userRepoMock.Object);
var changeRecord = new ChangeRecordDto { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Sum = 50, SpendingGroupId = Guid.NewGuid() };
Assert.ThrowsAsync<EntityNotFoundException>(() => changeRecordService.Update(changeRecord));
}
public IEnumerable<ChangeRecordDto> GetAllChangeRecords() => new List<ChangeRecordDto>()
{
new ChangeRecordDto() { Id = Guid.NewGuid(), Sum = 50, SpendingGroupId = Guid.NewGuid(), UserId = Guid.NewGuid() },
new ChangeRecordDto() { Id = Guid.NewGuid(), Sum = 50, SpendingGroupId = Guid.NewGuid(), UserId = Guid.NewGuid() },
};
}

View File

@ -0,0 +1,118 @@
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.Services;
using Contracts.ViewModels;
using Moq;
using Services.Support.Exceptions;
namespace Services.Tests.Domain;
public class SpendingGroupServiceTests
{
[Fact]
public async Task Create_ReturnsSpendingGroupViewModel()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Create(It.IsAny<SpendingGroupDto>())).ReturnsAsync(new SpendingGroupDto());
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
var result = await spendingGroupService.Create(new());
spendingGroupRepoMock.Verify(repo => repo.Create(It.IsAny<SpendingGroupDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingGroupViewModel>(result);
}
[Fact]
public async Task GetList_ReturnsSpendingGroupViewModels()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.GetList(It.IsAny<SpendingGroupSearch>())).ReturnsAsync(_getAllSpendingGroups());
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
var result = await spendingGroupService.GetList();
spendingGroupRepoMock.Verify(repo => repo.GetList(It.IsAny<SpendingGroupSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<List<SpendingGroupViewModel>>(result.ToList());
}
[Fact]
public async Task Delete_ReturnsSpendingGroupViewModel()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Delete(It.IsAny<SpendingGroupSearch>())).ReturnsAsync(new SpendingGroupDto());
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
var result = await spendingGroupService.Delete(new());
spendingGroupRepoMock.Verify(repo => repo.Delete(It.IsAny<SpendingGroupSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingGroupViewModel>(result);
}
[Fact]
public void Delete_WhenSpendingGroupNotFound_ThrowsEntityNotFoundException()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Delete(It.IsAny<SpendingGroupSearch>())).ReturnsAsync((SpendingGroupDto)null);
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
Assert.ThrowsAsync<EntityNotFoundException>(() => spendingGroupService.Delete(new()));
}
[Fact]
public async Task Update_ReturnsSpendingGroupViewModel()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Update(It.IsAny<SpendingGroupDto>())).ReturnsAsync(new SpendingGroupDto());
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
var result = await spendingGroupService.Update(new());
spendingGroupRepoMock.Verify(repo => repo.Update(It.IsAny<SpendingGroupDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingGroupViewModel>(result);
}
[Fact]
public void Update_WhenSpendingGroupNotFound_ThrowsEntityNotFoundException()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Update(It.IsAny<SpendingGroupDto>())).ReturnsAsync((SpendingGroupDto)null);
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
Assert.ThrowsAsync<EntityNotFoundException>(() => spendingGroupService.Update(new()));
}
[Fact]
public async Task GetDetails_ReturnsSpendingGroupViewModel()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Get(It.IsAny<SpendingGroupSearch>())).ReturnsAsync(new SpendingGroupDto());
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
var result = await spendingGroupService.GetDetails(new());
spendingGroupRepoMock.Verify(repo => repo.Get(It.IsAny<SpendingGroupSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingGroupViewModel>(result);
}
[Fact]
public void GetDetails_WhenSpendingGroupNotFound_ThrowsEntityNotFoundException()
{
var spendingGroupRepoMock = new Mock<ISpendingGroupRepo>();
spendingGroupRepoMock.Setup(repo => repo.Get(It.IsAny<SpendingGroupSearch>())).ReturnsAsync((SpendingGroupDto)null);
var spendingGroupService = new SpendingGroupService(spendingGroupRepoMock.Object);
Assert.ThrowsAsync<EntityNotFoundException>(() => spendingGroupService.GetDetails(new()));
}
private IEnumerable<SpendingGroupDto> _getAllSpendingGroups() => new List<SpendingGroupDto>()
{
new() { Id = Guid.NewGuid(), Name = "Group 1" },
new() { Id = Guid.NewGuid(), Name = "Group 2" }
};
}

View File

@ -0,0 +1,101 @@
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.ViewModels;
using Moq;
using Services.Domain;
using Services.Support.Exceptions;
namespace Services.Tests.Domain;
public class SpendingPlanServiceTests
{
[Fact]
public async Task Create_ReturnsSpendingPlanViewModel()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.Create(It.IsAny<SpendingPlanDto>())).ReturnsAsync(new SpendingPlanDto());
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
var result = await spendingPlanService.Create(new());
spendingPlanRepoMock.Verify(repo => repo.Create(It.IsAny<SpendingPlanDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingPlanViewModel>(result);
}
[Fact]
public async Task Update_WhenPlanExists_ThenUpdatePlan_ReturnsSpendingPlanViewModel()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.Update(It.IsAny<SpendingPlanDto>()))
.ReturnsAsync(new SpendingPlanDto());
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
var result = await spendingPlanService.Update(new());
spendingPlanRepoMock.Verify(repo => repo.Update(It.IsAny<SpendingPlanDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingPlanViewModel>(result);
}
[Fact]
public async Task Update_WhenPlanNotFound_ThenThrowsEntityNotFoundException()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.Update(It.IsAny<SpendingPlanDto>()))
.ReturnsAsync((SpendingPlanDto)null);
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
await Assert.ThrowsAsync<EntityNotFoundException>(() => spendingPlanService.Update(new()));
}
[Fact]
public async Task Delete_WhenPlanExists_ThenDeletePlan_ReturnsSpendingPlanViewModel()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.Delete(It.IsAny<SpendingPlanSearch>()))
.ReturnsAsync(new SpendingPlanDto());
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
var result = await spendingPlanService.Delete(new());
spendingPlanRepoMock.Verify(repo => repo.Delete(It.IsAny<SpendingPlanSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<SpendingPlanViewModel>(result);
}
[Fact]
public async Task Delete_WhenPlanNotFound_ThenThrowsEntityNotFoundException()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.Delete(It.IsAny<SpendingPlanSearch>()))
.ReturnsAsync((SpendingPlanDto)null);
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
await Assert.ThrowsAsync<EntityNotFoundException>(() => spendingPlanService.Delete(new()));
}
[Fact]
public async Task GetList_ReturnsSpendingPlanViewModels()
{
var spendingPlanRepoMock = new Mock<ISpendingPlanRepo>();
spendingPlanRepoMock.Setup(repo => repo.GetList(It.IsAny<SpendingPlanSearch>()))
.ReturnsAsync(_getAllSpendingPlans());
var spendingPlanService = new SpendingPlanService(spendingPlanRepoMock.Object);
var result = await spendingPlanService.GetList(new());
spendingPlanRepoMock.Verify(repo => repo.GetList(It.IsAny<SpendingPlanSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<List<SpendingPlanViewModel>>(result.ToList());
Assert.Equal(2, result.Count());
}
private IEnumerable<SpendingPlanDto> _getAllSpendingPlans() => new List<SpendingPlanDto>()
{
new() { Id = Guid.NewGuid(), StartAt = DateTime.Now, EndAt = DateTime.Now, Sum = 100 },
new() { Id = Guid.NewGuid(), StartAt = DateTime.Now, EndAt = DateTime.Now, Sum = 200 }
};
}

View File

@ -0,0 +1,74 @@
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.ViewModels;
using Moq;
using Services.Domain;
using Services.Support.Exceptions;
namespace Services.Tests.Domain;
public class UserServiceTests
{
[Fact]
public async Task Delete_WhenUserExists_ThenDeleteUser_ReturnsUserViewModel()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Delete(It.IsAny<UserSearch>())).ReturnsAsync(new UserDto());
var userService = new UserService(userRepoMock.Object);
var result = await userService.Delete(new UserSearch());
userRepoMock.Verify(repo => repo.Delete(It.IsAny<UserSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<UserViewModel>(result);
}
[Fact]
public void Delete_WhenUserNotExists_ThenThrowsUserNotFoundException()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Delete(It.IsAny<UserSearch>())).ReturnsAsync((UserDto?)null);
var userService = new UserService(userRepoMock.Object);
Assert.ThrowsAsync<UserNotFoundException>(() => userService.Delete(new UserSearch()));
}
[Fact]
public async Task Update_WhenUserExists_ThenUpdateUser_ReturnsUserViewModel()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Update(It.IsAny<UserDto>())).ReturnsAsync(new UserDto());
var userService = new UserService(userRepoMock.Object);
var result = await userService.UpdateUserData(new UserDto());
userRepoMock.Verify(repo => repo.Update(It.IsAny<UserDto>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<UserViewModel>(result);
}
[Fact]
public void Update_WhenUserNotFound_ThenThrowsUserNotFoundException()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Update(It.IsAny<UserDto>())).ReturnsAsync((UserDto?)null);
var userService = new UserService(userRepoMock.Object);
Assert.ThrowsAsync<UserNotFoundException>(() => userService.UpdateUserData(new UserDto()));
}
[Fact]
public async Task GetDetails_ReturnsUserViewModel()
{
var userRepoMock = new Mock<IUserRepo>();
userRepoMock.Setup(repo => repo.Get(It.IsAny<UserSearch>())).ReturnsAsync(new UserDto());
var userService = new UserService(userRepoMock.Object);
var result = await userService.GetDetails(new UserSearch());
userRepoMock.Verify(repo => repo.Get(It.IsAny<UserSearch>()), Times.Once);
Assert.NotNull(result);
Assert.IsType<UserViewModel>(result);
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Services\Services.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
using Contracts.DTO;
namespace Services.Tests.Support.Fakes.Database;
internal static class DbFake
{
public static List<UserDto> Users = new();
public static void ClearDb()
{
Users.Clear();
}
}

View File

@ -0,0 +1,55 @@
using System.Data.Common;
using Contracts.DTO;
using Contracts.Repositories;
using Contracts.SearchModels;
using Services.Tests.Support.Fakes.Database;
namespace Services.Tests.Support.Fakes.Repositories;
internal class UserRepoFake : IUserRepo
{
public async Task<bool> ChangeBalance(UserSearch search, decimal amount)
{
var user = DbFake.Users.FirstOrDefault(u => u.Id == search.Id);
if (user != null)
{
user.Balance += amount;
return await Task.FromResult(true);
}
return await Task.FromResult(false);
}
public async Task<UserDto> Create(UserDto user)
{
DbFake.Users.Add(user);
return await Task.FromResult(user);
}
public async Task<UserDto?> Delete(UserSearch search)
{
var user = DbFake.Users.FirstOrDefault(u => u.Id == search.Id);
if (user != null)
{
DbFake.Users.Remove(user);
}
return await Task.FromResult(user);
}
public async Task<UserDto?> Get(UserSearch search)
{
return await Task.FromResult(DbFake.Users.FirstOrDefault(u => u.Id == search.Id));
}
public async Task<UserDto?> Update(UserDto user)
{
var existingUser = DbFake.Users.FirstOrDefault(u => u.Id == user.Id);
if (existingUser != null)
{
existingUser.Name = user.Name;
existingUser.Password = user.Password;
DbFake.Users.Remove(existingUser);
DbFake.Users.Add(existingUser);
}
return await Task.FromResult(existingUser);
}
}

View File

@ -17,7 +17,7 @@ public class AuthService : IAuthService
_userRepo = userRepo;
}
public async Task<UserViewModel> Login(UserLoginDTO loginData)
public async Task<UserViewModel> Login(UserLoginDto loginData)
{
if (loginData == null || string.IsNullOrWhiteSpace(loginData.Name)
|| string.IsNullOrWhiteSpace(loginData.Password))

View File

@ -4,6 +4,7 @@ using Contracts.Repositories;
using Contracts.SearchModels;
using Contracts.Services;
using Contracts.ViewModels;
using Services.Support.Exceptions;
namespace Services.Domain;
@ -32,7 +33,7 @@ public class ChangeRecordService : IChangeRecordService
var record = await _changeRecordRepo.Delete(search);
if (record == null)
{
throw new EntryPointNotFoundException("При удалении не получилось найти запись измнения баланса");
throw new EntityNotFoundException("При удалении не получилось найти запись измнения баланса");
}
// Возвращает баланс обратно
await _userRepo.ChangeBalance(new() { Id = record.UserId }, -record.Sum);
@ -51,7 +52,7 @@ public class ChangeRecordService : IChangeRecordService
var record = await _changeRecordRepo.Update(model);
if (record == null)
{
throw new EntryPointNotFoundException("При изменении не получилось найти запись измнения баланса");
throw new EntityNotFoundException("При изменении не получилось найти запись измнения баланса");
}
await _userRepo.ChangeBalance(new() { Id = model.UserId }, model.Sum - record.Sum);
return record.ToView();

41
docker-compose.debug.yml Normal file
View File

@ -0,0 +1,41 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
services:
api:
image: api
build:
context: .
dockerfile: back/Controllers/Dockerfile
args:
- configuration=Debug
ports:
- 5125:5125
environment:
- ConnectionStrings__DefaultConnection=${CONNECTION_STRING}
- ASPNETCORE_ENVIRONMENT=Development
volumes:
- ~/.vsdbg:/remote_debugger:rw
depends_on:
- database
dombudg:
image: dombudg
build:
context: front
dockerfile: ./Dockerfile
environment:
- VITE_API_URL=http://api:5125
ports:
- 80:80
depends_on:
- api
database:
image: postgres:14
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
driver: local

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
api:
image: api
build:
context: .
dockerfile: back/Controllers/Dockerfile
ports:
- 5125:5125
environment:
- ConnectionStrings__DefaultConnection=${CONNECTION_STRING}
depends_on:
- database
dombudg:
image: dombudg
build:
context: front
dockerfile: ./Dockerfile
environment:
- VITE_API_URL=http://api:5125
ports:
- 80:80
depends_on:
- api
database:
image: postgres:14
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
driver: local

24
front/.dockerignore Normal file
View File

@ -0,0 +1,24 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

2
front/.gitignore vendored
View File

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

18
front/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node as vite-app
ARG VITE_API_URL
WORKDIR /app/client
COPY . .
RUN ["npm", "i"]
RUN ["npm", "run", "build"]
FROM nginx:alpine
COPY nginx.conf /etc/nginx
RUN rm -rf /usr/share/nginx/html/*
COPY --from=vite-app /app/client/dist /usr/share/nginx/html
ENTRYPOINT ["nginx", "-g", "daemon off;"]

38
front/components.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AButton: typeof import('ant-design-vue/es')['Button']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
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']
ChangeRecordManager: typeof import('./src/components/support/ChangeRecordManager.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']
PlanManager: typeof import('./src/components/support/PlanManager.vue')['default']
Plans: typeof import('./src/components/pages/Plans.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SignUp: typeof import('./src/components/pages/SignUp.vue')['default']
SpendingGroupManager: typeof import('./src/components/support/SpendingGroupManager.vue')['default']
}
}

55
front/nginx.conf Normal file
View File

@ -0,0 +1,55 @@
# Запускать в качестве менее привилегированного пользователя по соображениям безопасности..
user nginx;
# Значение auto устанавливает число максимально доступных ядер CPU,
# чтобы обеспечить лучшую производительность.
worker_processes auto;
events { worker_connections 1024; }
http {
server {
# Hide nginx version information.
server_tokens off;
listen 80;
root /usr/share/nginx/html;
include /etc/nginx/mime.types;
location /api/ {
proxy_pass http://api:5125/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /test;
}
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_vary on;
gzip_http_version 1.0;
gzip_comp_level 5;
gzip_types
application/atom+xml
application/javascript
application/json
application/rss+xml
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/svg+xml
image/x-icon
text/css
text/plain
text/x-component;
gzip_proxied no-cache no-store private expired auth;
gzip_min_length 256;
gunzip on;
}
}

1716
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,14 +6,22 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"gen-api": "swagger-typescript-api -r -o ./src/core/api/ --modular -p "
},
"dependencies": {
"vue": "^3.5.12"
"@vueuse/core": "^12.0.0",
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.13",
"pinia": "^2.2.8",
"vue": "^3.5.12",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"swagger-typescript-api": "^13.0.23",
"typescript": "~5.6.2",
"unplugin-vue-components": "^0.27.5",
"vite": "^5.4.10",
"vue-tsc": "^2.1.8"
}

View File

@ -1,9 +1,26 @@
<script setup lang="ts">
import Header from './components/main/Header.vue';
</script>
<template>
<a-layout class="layout">
<Header />
<a-layout-content>
<RouterView />
</a-layout-content>
</a-layout>
</template>
<style scoped>
</style>
main {
display: flex;
justify-content: center;
padding: 5vh;
}
.base-page {
display: flex;
flex-direction: column;
min-width: 80dvw;
}
</style>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useUserStore } from '../../store';
import { AuthService } from '../../core/services/auth-service';
import router from '../../router';
const store = useUserStore();
const authService = inject(AuthService.name) as AuthService;
function logout() {
authService.logout();
router.push({ name: 'login' });
}
</script>
<template>
<a-layout-header class="header">
<div class="base-nav">
<div>ДомБюдж</div>
<nav>
<RouterLink :to="{ name: 'home' }">Главная</RouterLink>
<RouterLink :to="{ name: 'groups' }">Группы расходов</RouterLink>
</nav>
</div>
<div v-if="!store.user.id">
<RouterLink :to="{ name: 'login' }">Войти</RouterLink>
</div>
<div v-else>
<label for="logout">Привет, {{ store.user.name }}! Ваш текущий баланс: {{ store.user.balance }}</label>
<a-button
name="logout"
@click="logout()"
danger
style="margin-left: 30px"
>
Выйти
</a-button>
</div>
</a-layout-header>
</template>
<style scoped>
.header {
background-color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.base-nav {
display: inline-flex;
justify-content: left;
}
.base-nav a {
margin-left: 30px;
}
</style>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
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';
const groupService = inject(GroupService.name) as GroupService;
const { state, isReady } = useAsyncState(() => groupService.getList(), []);
const columns = [
{
title: "Название группы",
dataIndex: "name",
key: "name",
},
{
title: "Планы группы",
dataIndex: "plans",
key: "plans",
},
{
title: 'Операция',
dataIndex: 'operation',
key: 'operation',
}
]
const refreshData = () => {
groupService.getList().then(data => {
state.value = data;
isReady.value = true;
});
}
const onDelete = (key: string) => {
groupService.deleteGroup(key)
.then(() => {
refreshData();
})
}
</script>
<template>
<div class="base-page">
<h1>Группы расходов</h1>
<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>
</template>
<template v-else-if="column.dataIndex === 'operation'">
<a-popconfirm
v-if="state?.length"
title="Точно удалить?"
@confirm="onDelete(record.id)"
>
<a><DeleteOutlined /> Удалить</a>
</a-popconfirm>
</template>
</template>
</a-table>
<div v-else>
<a-spin size="large" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core';
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';
const changeRecordService = inject(ChangeRecordService.name) as ChangeRecordService;
const { state, isReady } = useAsyncState(() => changeRecordService.getList(), []);
const columns = [
{
title: 'Дата',
dataIndex: 'changedAt',
key: 'changedAt',
},
{
title: 'Сумма',
dataIndex: 'sum',
key: 'sum',
},
{
title: 'Группа расходов',
dataIndex: 'spendingGroupName',
key: 'spendingGroupName',
},
{
title: 'Операция',
dataIndex: 'operation',
key: 'operation',
}
]
const refreshData = () => {
changeRecordService.getList().then(data => {
state.value = data;
isReady.value = true;
});
}
const onDelete = (key: string) => {
changeRecordService.deleteRecord(key)
.then(() => {
refreshData();
})
}
</script>
<template>
<div class="base-page">
<h1>История изменений баланса</h1>
<ChangeRecordMenu :refreshData="refreshData" />
<a-table :dataSource="state" :columns="columns" v-if="isReady" >
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'operation'">
<a-popconfirm
v-if="state?.length"
title="Точно удалить?"
@confirm="onDelete(record.id)"
>
<a><DeleteOutlined /> Удалить</a>
</a-popconfirm>
</template>
</template>
</a-table>
<div v-else>
<a-spin size="large" />
</div>
</div>
</template>
<style scoped>
.layout {
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<a-form
:model="formState"
name="login"
class="login-form"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item
label="Логин"
name="name"
:rules="[{ required: true, message: 'Пожалуйста, введите свой логин' }]"
>
<a-input v-model:value="formState.name">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item
label="Пароль"
name="password"
:rules="[{ required: true, message: 'Пароль тоже нужен!' }]"
>
<a-input-password v-model:value="formState.password">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button :disabled="disabled" type="primary" html-type="submit" class="login-form-button">
Войти
</a-button>
Или
<RouterLink :to="{ name: 'signup' }">создать аккаунт</RouterLink>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import { reactive, computed, inject } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { UserLoginDto } from '../../core/api/data-contracts';
import { RouterLink } from 'vue-router';
import { AuthService } from '../../core/services/auth-service';
import router from '../../router';
const formState = reactive<UserLoginDto>({
name: '',
password: '',
});
const authService = inject(AuthService.name) as AuthService;
console.log(authService);
// Логика формы
const onFinish = async (values: any) => {
console.log('Success:', values);
await authService.login(formState);
router.push({ name: 'home' });
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabled = computed(() => {
return !(formState.name && formState.password);
});
</script>
<style scoped>
.login-form {
max-width: 300px;
}
.login-form-button {
width: 100%;
}
</style>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core';
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';
const planService = inject(PlanService.name) as PlanService;
const groupId = useRoute().params.groupId as string;
const { state, isReady } = useAsyncState(() => planService.getList(groupId), []);
const columns = [
{
title: "Планируемые расходы",
dataIndex: "sum",
key: "sum",
},
{
title: "Начало плана",
dataIndex: "startAt",
key: "startAt",
},
{
title: "Конец плана",
dataIndex: "endAt",
key: "endAt",
},
{
title: 'Операция',
dataIndex: 'operation',
key: 'operation',
}
]
const refreshData = () => {
planService.getList(groupId).then(data => {
state.value = data;
isReady.value = true;
});
};
const onDelete = (key: string) => {
planService.deletePlan(key)
.then(() => {
refreshData();
})
}
</script>
<template>
<div class="base-page">
<h1>Планы группы</h1>
<PlanManager :groupId="groupId" :refreshData="refreshData"/>
<a-table :dataSource="state" :columns="columns" v-if="isReady">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'operation'">
<a-popconfirm
v-if="state?.length"
title="Точно удалить?"
@confirm="onDelete(record.id)"
>
<a><DeleteOutlined /> Удалить</a>
</a-popconfirm>
</template>
</template>
</a-table>
<div v-else>
<a-spin size="large" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,86 @@
<template>
<a-form
:model="formState"
name="signup"
class="signup-form"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item
label="Логин"
name="name"
:rules="[{ required: true, message: 'Пожалуйста, введите свой логин' }]"
>
<a-input v-model:value="formState.name">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item
label="Пароль"
name="password"
:rules="[{ required: true, message: 'Пароль тоже нужен!' }]"
>
<a-input-password v-model:value="formState.password">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item
label="Баланс"
name="balance"
:rules="[{ required: true, message: 'Пожалуйста, введите свой баланс' }]"
>
<a-input-number prefix="₽" v-model:value="formState.balance" min="0"/>
</a-form-item>
<a-form-item>
<a-button :disabled="disabled" type="primary" html-type="submit" class="signup-form-button">
Создать
</a-button>
Или
<RouterLink :to="{ name: 'login' }">войти в свой аккаунт</RouterLink>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import { reactive, computed, inject } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { UserDto } from '../../core/api/data-contracts';
import { RouterLink } from 'vue-router';
import { AuthService } from '../../core/services/auth-service';
import router from '../../router';
const formState = reactive<UserDto>({
name: '',
password: '',
balance: 0
});
const authService = inject(AuthService.name) as AuthService;
// Логика формы
const onFinish = async (values: any) => {
console.log('Success:', values);
await authService.register(formState);
router.push({ name: 'home' });
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabled = computed(() => {
return !(formState.name && formState.password && formState.balance);
});
</script>
<style scoped>
.signup-form {
max-width: 300px;
}
.signup-form-button {
width: 100%;
}
</style>

View File

@ -0,0 +1,95 @@
<style scoped>
</style>
<template>
<a-space direction="vertical" :size="10">
<label for="change-record">Записать изменения баланса</label>
<a-form
:model="formState"
name="change-record"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item
label="Сумма"
name="sum"
:rules="[{ required: true, message: 'Пожалуйста, введите сумму' }]"
>
<a-input-number v-model:value="formState.sum" />
</a-form-item>
<a-form-item
label="Группа расходов"
name="spendingGroupId"
v-if="isReady"
:rules="[{ required: true, message: 'Пожалуйста, выберите группу расходов' }]"
>
<a-select v-model:value="formState.spendingGroupId" :disabled="disabled">
<a-select-option v-for="group in groupList" :key="group.id" :value="group.id">
{{ group.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-else>
<a-spin size="small" />
</a-form-item>
<a-form-item
name="changedAt"
label="Дата"
:rules="[{ required: true, message: 'Пожалуйста, выберите дату' }]"
>
<a-date-picker v-model:value="pickedChangedAt" />
</a-form-item>
<a-form-item>
<a-button html-type="submit" :disabled="disabledForm" type="primary">Сохранить</a-button>
</a-form-item>
</a-form>
</a-space>
</template>
<script setup lang="ts">
import { computed, inject, reactive, ref } from 'vue';
import { ChangeRecordDto } from '../../core/api/data-contracts';
import { useUserStore } from '../../store';
import { ChangeRecordService } from '../../core/services/change-record-service';
import { GroupService } from '../../core/services/group-service';
import { useAsyncState } from '@vueuse/core';
import type { Dayjs } from 'dayjs';
interface IProps {
refreshData: () => void
}
const { refreshData } = defineProps<IProps>();
const store = useUserStore();
// Сервисы
const changeRecordService = inject(ChangeRecordService.name) as ChangeRecordService;
const groupService = inject(GroupService.name) as GroupService;
const { state: groupList, isReady } = useAsyncState(() => groupService.getList(), []);
const pickedChangedAt = ref<Dayjs>();
const formState = reactive<ChangeRecordDto>({
userId: store.user.id,
sum: -1,
changedAt: new Date().toISOString(),
spendingGroupId: null
});
// Логика формы
const onFinish = async (values: any) => {
console.log('Success:', values);
formState.changedAt = pickedChangedAt.value?.toISOString();
await changeRecordService.createRecord(formState);
refreshData();
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabled = computed(() => {
return formState.sum && formState.sum >= 0;
});
const disabledForm = computed(() => {
return !(formState.sum && formState.changedAt && (formState.spendingGroupId || formState.sum > 0));
})
</script>

View File

@ -0,0 +1,80 @@
<style scoped>
</style>
<template>
<a-space direction="vertical" :size="10">
<label for="change-record">Добавить план расходов</label>
<a-form
:model="formState"
name="change-record"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item
label="Сумма"
name="sum"
:rules="[{ required: true, message: 'Пожалуйста, введите сумму' }]"
>
<a-input-number v-model:value="formState.sum" />
</a-form-item>
<a-form-item
name="startAt"
label="Дата начала плана"
:rules="[{ required: true, message: 'Пожалуйста, введите дату начала плана' }]"
>
<a-date-picker v-model:value="startAt" />
</a-form-item>
<a-form-item
name="endAt"
label="Дата окончания плана"
:rules="[{ required: true, message: 'Пожалуйста, введите дату окончания плана' }]"
>
<a-date-picker v-model:value="endAt" />
</a-form-item>
<a-form-item>
<a-button :disabled="disabledForm" html-type="submit" type="primary">Добавить</a-button>
</a-form-item>
</a-form>
</a-space>
</template>
<script setup lang="ts">
import { computed, inject, reactive, ref } from 'vue';
import { SpendingPlanDto } from '../../core/api/data-contracts';
import { Dayjs } from 'dayjs';
import { PlanService } from '../../core/services/plans-service';
interface IProps {
groupId: string,
refreshData: () => void
}
const { groupId, refreshData } = defineProps<IProps>();
// Сервисы
const planService = inject(PlanService.name) as PlanService;
const startAt = ref<Dayjs>();
const endAt = ref<Dayjs>();
const formState = reactive<SpendingPlanDto>({
sum: 0,
startAt: new Date().toISOString(),
endAt: new Date().toISOString(),
spendingGroupId: groupId
});
// Логика формы
const onFinish = async (values: any) => {
console.log('Success:', values);
formState.startAt = startAt.value?.toISOString();
formState.endAt = endAt.value?.toISOString();
await planService.createPlan(formState);
refreshData();
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabledForm = computed(() => {
return !(formState.sum && formState.startAt && formState.endAt);
});
</script>

View File

@ -0,0 +1,57 @@
<template>
<a-space direction="vertical" :size="10">
<label for="spending-group">Создать группу расходов</label>
<a-form
:model="formState"
name="spending-group"
@finish="onFinish"
@finishFailed="onFinishFailed"
>
<a-form-item
label="Название"
name="name"
:rules="[{ required: true, message: 'Пожалуйста, введите название' }]"
>
<a-input v-model:value="formState.name" />
</a-form-item>
<a-form-item>
<a-button :disabled="disabledForm" html-type="submit" type="primary">Сохранить</a-button>
</a-form-item>
</a-form>
</a-space>
</template>
<script setup lang="ts">
import { computed, inject, reactive } from 'vue';
import { useUserStore } from '../../store';
import { GroupService } from '../../core/services/group-service';
import { SpendingGroupDto } from '../../core/api/data-contracts';
interface IProps {
refreshData: () => void
}
const { refreshData } = defineProps<IProps>();
const store = useUserStore();
// Сервисы
const groupService = inject(GroupService.name) as GroupService;
const formState = reactive<SpendingGroupDto>({
userId: store.user.id,
name: '',
});
// Логика формы
const onFinish = async (values: any) => {
console.log('Success:', values);
await groupService.createGroup(formState);
refreshData();
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabledForm = computed(() => {
return !formState.name;
});
</script>

470
front/src/core/api/Api.ts Normal file
View File

@ -0,0 +1,470 @@
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import {
ChangeRecordDto,
ChangeRecordViewModel,
SpendingGroupDto,
SpendingGroupViewModel,
SpendingPlanDto,
SpendingPlanViewModel,
UserDto,
UserLoginDto,
UserViewModel,
} from "./data-contracts";
import { ContentType, HttpClient, RequestParams } from "./http-client";
export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType> {
/**
* No description
*
* @tags Auth
* @name AuthCreate
* @request POST:/api/Auth
* @response `200` `UserViewModel` Success
*/
auth = (data: UserLoginDto, params: RequestParams = {}) =>
this.request<UserViewModel, any>({
path: `/api/Auth`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags Auth
* @name AuthRegisterCreate
* @request POST:/api/Auth/register
* @response `200` `UserViewModel` Success
*/
authRegister = (data: UserDto, params: RequestParams = {}) =>
this.request<UserViewModel, any>({
path: `/api/Auth/register`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags ChangeRecord
* @name ChangeRecordCreate
* @request POST:/api/ChangeRecord
* @response `200` `ChangeRecordViewModel` Success
*/
changeRecordCreate = (data: ChangeRecordDto, params: RequestParams = {}) =>
this.request<ChangeRecordViewModel, any>({
path: `/api/ChangeRecord`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags ChangeRecord
* @name ChangeRecordList
* @request GET:/api/ChangeRecord
* @response `200` `(ChangeRecordViewModel)[]` Success
*/
changeRecordList = (params: RequestParams = {}) =>
this.request<ChangeRecordViewModel[], any>({
path: `/api/ChangeRecord`,
method: "GET",
format: "json",
...params,
});
/**
* No description
*
* @tags ChangeRecord
* @name ChangeRecordPartialUpdate
* @request PATCH:/api/ChangeRecord
* @response `200` `ChangeRecordViewModel` Success
*/
changeRecordPartialUpdate = (data: ChangeRecordDto, params: RequestParams = {}) =>
this.request<ChangeRecordViewModel, any>({
path: `/api/ChangeRecord`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags ChangeRecord
* @name ChangeRecordDelete
* @request DELETE:/api/ChangeRecord
* @response `200` `void` Success
*/
changeRecordDelete = (
query?: {
/** @format uuid */
Id?: string;
/** @format uuid */
SpendingGroupId?: string;
/** @format date-time */
From?: string;
/** @format date-time */
To?: string;
/** @format uuid */
UserId?: string;
},
params: RequestParams = {},
) =>
this.request<void, any>({
path: `/api/ChangeRecord`,
method: "DELETE",
query: query,
...params,
});
/**
* No description
*
* @tags ChangeRecord
* @name ChangeRecordFilterList
* @request GET:/api/ChangeRecord/filter
* @response `200` `(ChangeRecordViewModel)[]` Success
*/
changeRecordFilterList = (
query?: {
/** @format uuid */
Id?: string;
/** @format uuid */
SpendingGroupId?: string;
/** @format date-time */
From?: string;
/** @format date-time */
To?: string;
/** @format uuid */
UserId?: string;
},
params: RequestParams = {},
) =>
this.request<ChangeRecordViewModel[], any>({
path: `/api/ChangeRecord/filter`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupDetail
* @request GET:/api/SpendingGroup/{id}
* @response `200` `SpendingGroupViewModel` Success
*/
spendingGroupDetail = (
id: string,
query?: {
/** @format uuid */
Id?: string;
Name?: string;
/** @format uuid */
UserId?: string;
},
params: RequestParams = {},
) =>
this.request<SpendingGroupViewModel, any>({
path: `/api/SpendingGroup/${id}`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupList
* @request GET:/api/SpendingGroup
* @response `200` `(SpendingGroupViewModel)[]` Success
*/
spendingGroupList = (params: RequestParams = {}) =>
this.request<SpendingGroupViewModel[], any>({
path: `/api/SpendingGroup`,
method: "GET",
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupCreate
* @request POST:/api/SpendingGroup
* @response `200` `SpendingGroupViewModel` Success
*/
spendingGroupCreate = (data: SpendingGroupDto, params: RequestParams = {}) =>
this.request<SpendingGroupViewModel, any>({
path: `/api/SpendingGroup`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupPartialUpdate
* @request PATCH:/api/SpendingGroup
* @response `200` `SpendingGroupViewModel` Success
*/
spendingGroupPartialUpdate = (data: SpendingGroupDto, params: RequestParams = {}) =>
this.request<SpendingGroupViewModel, any>({
path: `/api/SpendingGroup`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupDelete
* @request DELETE:/api/SpendingGroup
* @response `200` `void` Success
*/
spendingGroupDelete = (
query?: {
/** @format uuid */
Id?: string;
Name?: string;
/** @format uuid */
UserId?: string;
},
params: RequestParams = {},
) =>
this.request<void, any>({
path: `/api/SpendingGroup`,
method: "DELETE",
query: query,
...params,
});
/**
* No description
*
* @tags SpendingGroup
* @name SpendingGroupFilterList
* @request GET:/api/SpendingGroup/filter
* @response `200` `(SpendingGroupViewModel)[]` Success
*/
spendingGroupFilterList = (
query?: {
/** @format uuid */
Id?: string;
Name?: string;
/** @format uuid */
UserId?: string;
},
params: RequestParams = {},
) =>
this.request<SpendingGroupViewModel[], any>({
path: `/api/SpendingGroup/filter`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanDetail
* @request GET:/api/SpendingPlan/{id}
* @response `200` `SpendingPlanViewModel` Success
*/
spendingPlanDetail = (
id: string,
query?: {
/** @format uuid */
Id?: string;
},
params: RequestParams = {},
) =>
this.request<SpendingPlanViewModel, any>({
path: `/api/SpendingPlan/${id}`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanList
* @request GET:/api/SpendingPlan
* @response `200` `(SpendingPlanViewModel)[]` Success
*/
spendingPlanList = (params: RequestParams = {}) =>
this.request<SpendingPlanViewModel[], any>({
path: `/api/SpendingPlan`,
method: "GET",
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanCreate
* @request POST:/api/SpendingPlan
* @response `200` `SpendingPlanViewModel` Success
*/
spendingPlanCreate = (data: SpendingPlanDto, params: RequestParams = {}) =>
this.request<SpendingPlanViewModel, any>({
path: `/api/SpendingPlan`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanPartialUpdate
* @request PATCH:/api/SpendingPlan
* @response `200` `SpendingPlanViewModel` Success
*/
spendingPlanPartialUpdate = (data: SpendingPlanDto, params: RequestParams = {}) =>
this.request<SpendingPlanViewModel, any>({
path: `/api/SpendingPlan`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanDelete
* @request DELETE:/api/SpendingPlan
* @response `200` `void` Success
*/
spendingPlanDelete = (
query?: {
/** @format uuid */
Id?: string;
},
params: RequestParams = {},
) =>
this.request<void, any>({
path: `/api/SpendingPlan`,
method: "DELETE",
query: query,
...params,
});
/**
* No description
*
* @tags SpendingPlan
* @name SpendingPlanFilterList
* @request GET:/api/SpendingPlan/filter
* @response `200` `(SpendingPlanViewModel)[]` Success
*/
spendingPlanFilterList = (
query?: {
/** @format uuid */
Id?: string;
},
params: RequestParams = {},
) =>
this.request<SpendingPlanViewModel[], any>({
path: `/api/SpendingPlan/filter`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags User
* @name UserList
* @request GET:/api/User
* @response `200` `UserViewModel` Success
*/
userGet = (
query?: {
/** @format uuid */
Id?: string;
Name?: string;
},
params: RequestParams = {},
) =>
this.request<UserViewModel, any>({
path: `/api/User`,
method: "GET",
query: query,
format: "json",
...params,
});
/**
* No description
*
* @tags User
* @name UserPartialUpdate
* @request PATCH:/api/User
* @response `200` `UserViewModel` Success
*/
userPartialUpdate = (data: UserDto, params: RequestParams = {}) =>
this.request<UserViewModel, any>({
path: `/api/User`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
});
/**
* No description
*
* @tags User
* @name UserDelete
* @request DELETE:/api/User
* @response `200` `UserViewModel` Success
*/
userDelete = (
query?: {
/** @format uuid */
Id?: string;
Name?: string;
},
params: RequestParams = {},
) =>
this.request<UserViewModel, any>({
path: `/api/User`,
method: "DELETE",
query: query,
format: "json",
...params,
});
}

View File

@ -0,0 +1,98 @@
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export interface ChangeRecordDto {
/** @format uuid */
id?: string;
/** @format uuid */
userId?: string;
/** @format uuid */
spendingGroupId?: string | null;
spendingGroupName?: string | null;
/** @format double */
sum?: number;
/** @format date-time */
changedAt?: string;
}
export interface ChangeRecordViewModel {
/** @format uuid */
id?: string;
/** @format double */
sum?: number;
/** @format date-time */
changedAt?: string;
spendingGroupName?: string | null;
}
export interface SpendingGroupDto {
/** @format uuid */
id?: string;
name?: string | null;
/** @format uuid */
userId?: string;
changeRecords?: ChangeRecordDto[] | null;
spendingPlans?: SpendingPlanDto[] | null;
}
export interface SpendingGroupViewModel {
/** @format uuid */
id?: string;
name?: string | null;
changeRecords?: ChangeRecordViewModel[] | null;
spendingPlans?: SpendingPlanViewModel[] | null;
}
export interface SpendingPlanDto {
/** @format uuid */
id?: string;
/** @format uuid */
spendingGroupId?: string;
/** @format double */
sum?: number;
/** @format date-time */
startAt?: string;
/** @format date-time */
endAt?: string;
}
export interface SpendingPlanViewModel {
/** @format uuid */
id?: string;
/** @format date-time */
startAt?: string;
/** @format date-time */
endAt?: string;
/** @format double */
sum?: number;
}
export interface UserDto {
/** @format uuid */
id?: string;
name?: string | null;
password?: string | null;
/** @format double */
balance?: number;
}
export interface UserLoginDto {
name?: string | null;
password?: string | null;
}
export interface UserViewModel {
/** @format uuid */
id?: string;
name?: string | null;
/** @format double */
balance?: number;
}

View File

@ -0,0 +1,220 @@
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = import.meta.env.VITE_API_URL;
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) => (Array.isArray(query[key]) ? this.addArrayQueryParam(query, key) : this.addQueryParam(query, key)))
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.Text]: (input: any) => (input !== null && typeof input !== "string" ? JSON.stringify(input) : input),
[ContentType.FormData]: (input: any) =>
Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData()),
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, {
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
},
signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
}).then(async (response) => {
const r = response.clone() as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const data = !responseFormat
? r
: await response[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}

View File

@ -0,0 +1,30 @@
import { useUserStore } from "../../store";
import { Api } from "../api/Api";
import { UserDto, UserLoginDto, UserViewModel } from "../api/data-contracts";
export class AuthService {
private readonly _api: Api;
constructor(api: Api) {
this._api = api;
}
async login(data: UserLoginDto) {
let result = await this._api.auth(data);
console.log(result);
const store = useUserStore();
store.updateUser(result.data);
}
logout() {
const store = useUserStore();
store.updateUser({} as UserViewModel);
}
async register(data: UserDto) {
let result = await this._api.authRegister(data);
const store = useUserStore();
store.updateUser(result.data);
}
}

View File

@ -0,0 +1,42 @@
import { useUserStore } from "../../store";
import { Api } from "../api/Api";
import { ChangeRecordDto, ChangeRecordViewModel } from "../api/data-contracts";
export class ChangeRecordService {
private readonly _api: Api
constructor(api: Api) {
this._api = api;
}
private async _updateUser() {
const store = useUserStore();
let updatedUser = await this._api.userGet({ Id: store.user.id });
store.updateUser(updatedUser.data);
}
async createRecord(data: ChangeRecordDto) {
let result = await this._api.changeRecordCreate(data);
this._updateUser();
console.log(result);
}
async deleteRecord(Id: string) {
console.log(Id);
let result = await this._api.changeRecordDelete({ Id });
this._updateUser();
console.log("delete");
console.log(result);
}
async getList(): Promise<ChangeRecordViewModel[] | null> {
const store = useUserStore();
if (!store.user.id) return null;
let UserId = store.user.id;
let result = await this._api.changeRecordFilterList({ UserId });
console.log(result);
return result.data;
}
}

View File

@ -0,0 +1,33 @@
import { useUserStore } from "../../store";
import { Api } from "../api/Api";
import { SpendingGroupDto, SpendingGroupViewModel } from "../api/data-contracts";
export class GroupService {
private readonly _api: Api;
constructor(api: Api) {
this._api = api;
}
async getList(): Promise<SpendingGroupViewModel[] | null> {
const store = useUserStore();
console.log("get list " + store.user.id);
if (!store.user.id) return null;
let UserId = store.user.id;
let result = await this._api.spendingGroupFilterList({ UserId });
console.log(result);
return result.data;
}
async deleteGroup(Id: string) {
let result = await this._api.spendingGroupDelete({ Id });
console.log("delete");
console.log(result);
}
async createGroup(data: SpendingGroupDto) {
let result = await this._api.spendingGroupCreate(data);
console.log(result);
}
}

View File

@ -0,0 +1,26 @@
import { Api } from "../api/Api";
import { SpendingPlanDto, SpendingPlanViewModel } from "../api/data-contracts";
export class PlanService {
private readonly _api: Api
constructor(api: Api) {
this._api = api;
}
async deletePlan(Id: string) {
let result = await this._api.spendingPlanDelete({ Id });
console.log("delete");
console.log(result);
}
async getList(groupId: string): Promise<SpendingPlanViewModel[] | null> {
const result = await this._api.spendingGroupDetail(groupId);
return result.data.spendingPlans || null;
}
async createPlan(data: SpendingPlanDto) {
const result = await this._api.spendingPlanCreate(data);
console.log(result);
}
}

View File

@ -1,5 +1,24 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
import { Api } from './core/api/Api'
import { createPinia } from 'pinia'
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'
createApp(App).mount('#app')
const app = createApp(App)
app.use(router)
app.use(createPinia())
// Di
const api = new Api();
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.mount('#app')

46
front/src/router.ts Normal file
View File

@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from './store';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('./components/pages/Home.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('./components/pages/Login.vue'),
meta: { notRequiresAuth: true },
},
{
path: '/signup',
name: 'signup',
component: () => import('./components/pages/SignUp.vue'),
meta: { notRequiresAuth: true },
},
{
path: '/groups',
name: 'groups',
component: () => import('./components/pages/Groups.vue'),
},
{
path: '/plans/:groupId',
name: 'plans',
component: () => import('./components/pages/Plans.vue'),
}
],
});
router.beforeEach((to, _, next) => {
const store = useUserStore();
if (!to.meta.notRequiresAuth && !store.user.id) {
next({ name: 'login' });
} else {
next();
}
});
export default router;

19
front/src/store.ts Normal file
View File

@ -0,0 +1,19 @@
import { UserViewModel } from './core/api/data-contracts'
import { defineStore } from 'pinia'
export interface State {
user: UserViewModel
}
export const useUserStore = defineStore('user', {
state: (): State => ({
user: {} as UserViewModel
}),
actions: {
updateUser(payload: UserViewModel) {
if (!payload) return;
this.user = payload;
},
}
})

View File

@ -1,7 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false, // css in js
}),
],
}),
],
})