feat: add admin pannel (CRUD for routes) & ticket pdf download

This commit is contained in:
cuqmbr 2022-06-14 16:07:45 +03:00
parent 4302b1f796
commit 609f989945
35 changed files with 1778 additions and 50 deletions

View File

@ -4,30 +4,28 @@
### Встановлення:
Скомпільований (на linux) та налаштований проєкт знаходиться у каталозі ~/TicketOffice/bin/Release/net6.0
Якщо немає ніяких проблем із його запуском можна пропустити наступні пункти
1. Compile the project (`[~]$ dotnet build -c Release` or in IDE)
2. Copy ~/TicketOffice/wwwroot to the root directory of compiled project (clean database can be found in ~/wwwroot/db)
3. Launch
1. Скомпілювати проєкт ([~]$ dotnet build -c Release або за допомогою інтегрованої середи розробки)
2. Скопіювати каталог ~/wwwroot/ у кореневий каталог скомпільованого проєкту (Чиста БД міститься у ~/wwwroot/db. Вона заповнюється інформацією самостійно, при першому запуску програми)
3. Запустити скомпільований проєкт
* ~ кореневий каталог вихідного коду проєкту
### Доступні маршрути:
### Available routes:
#### № 027
- Сватове -> Красноріченське -> Кремінна -> Рубіжне -> Сєвєродонецьк -> Лисичанськ -> Сєвєродонецьк -> Рубіжне -> Кремінна -> Красноріченське -> Сватове
- (Дата: дата першого запуску проєкту після додавання пустого файлу бази даних)
- (Date: date of the first launch of project with clean database)
#### № 013
- Кремінна -> Рубіжне -> Сєвєродонецьк -> Станиця Луганська -> Сєвєродонецьк -> Рубіжне -> Кремінна
- (Дата: дата першого запуску проєкту після додавання пустого файлу бази даних)
- (Date: date of the first launch of project with clean database)
### Тестовий аккаунт
### Test user account
#### e-mail: danylo.nazarko@nure.ua
#### password: *Hashed Password*
- e-mail: user
- password: user
#### Альтернативно можно створити власний аккаунт.
### Test admin account
- e-mail: admin
- password: admin

View File

@ -11,7 +11,7 @@ using TicketOffice.Data;
namespace TicketOffice.Migrations
{
[DbContext(typeof(TicketOfficeContext))]
[Migration("20220529081528_Initial_Create")]
[Migration("20220609073909_Initial_Create")]
partial class Initial_Create
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -133,6 +133,9 @@ namespace TicketOffice.Migrations
.HasMaxLength(48)
.HasColumnType("TEXT");
b.Property<bool>("IsManager")
.HasColumnType("INTEGER");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(32)

View File

@ -30,7 +30,8 @@ namespace TicketOffice.Migrations
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Email = table.Column<string>(type: "TEXT", maxLength: 48, nullable: false),
Password = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
Password = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
IsManager = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{

View File

@ -131,6 +131,9 @@ namespace TicketOffice.Migrations
.HasMaxLength(48)
.HasColumnType("TEXT");
b.Property<bool>("IsManager")
.HasColumnType("INTEGER");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(32)

View File

@ -0,0 +1,7 @@
namespace TicketOffice.Models;
public class DateString
{
public string? DepartureDate { get; set; }
public string? ArrivalDate { get; set; }
}

View File

@ -1,15 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace TicketOffice.Models;
public class Route
{
[Key]
[BindRequired]
public int Id { get; set; }
[Required(ErrorMessage = "Поле має бути заповненим")]
[Display(Name = "Номер")]
[Range(1, 256)]
[Range(1, 9999)]
public int Number { get; set; }
[Required(ErrorMessage = "Поле має бути заповненим")]
@ -18,7 +20,7 @@ public class Route
public int Capacity { get; set; }
[Required]
public ICollection<RouteCity> Cities { get; set; } = null!;
public List<RouteCity> Cities { get; set; } = null!;
public ICollection<Ticket>? Tickets { get; set; }
public List<Ticket>? Tickets { get; set; }
}

View File

@ -28,8 +28,15 @@ public class SeedData
{
new User
{
Email = "danylo.nazarko@nure.ua",
Password = "*Hashed Password*",
Email = "admin",
Password = "admin",
IsManager = true
},
new User
{
Email = "user",
Password = "user",
IsManager = false
}
});

View File

@ -25,6 +25,10 @@ public class User
ErrorMessage = "Проль має містити великі та малі латинські літери, цифри та спеціальні знаки (@, $, % та ін.)")]
public string Password { get; set; } = null!;
[Required(ErrorMessage = "Поле має бути заповненим")]
[Display(Name = "Адмімістратор?")]
public bool IsManager { get; set; }
public ICollection<Ticket>? Tickets { get; set; }
}

View File

@ -54,6 +54,7 @@
</div>
<div class="ticket-footer">
<a class="ticket-link-btn" onclick="document.getElementById('popup-city-list-@ticket.Id').style.display = 'inherit'">Маршрут</a>
<a class="ticket-link-btn" asp-page-handler="TicketPdf" asp-route-pdfTicketId="@ticket.Id">Скачати квиток</a>
<a class="ticket-link-btn" onclick="document.getElementById('popup-info-@ticket.Id').style.display = 'inherit'">Повернути</a>
</div>
</div>
@ -154,7 +155,7 @@
</div>
<div class="popup-info-footer">
<a class="popup-info-footer-link-button" onclick="document.getElementById('popup-info-@ticket.Id').style.display = 'none'">Відмінити</a>
<a class="popup-info-footer-link-button" asp-page-handler="ReturnTicket" asp-route-ReturnTicketId="@ticket.Id">Повернути</a>
<a class="popup-info-footer-link-button" asp-page-handler="ReturnTicket" asp-route-returnTicketId="@ticket.Id">Повернути</a>
</div>
</div>
</div>

View File

@ -3,31 +3,36 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
namespace TicketOffice.Pages.Auth;
public class AccountModel : PageModel
{
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
private readonly PdfService pdfService;
public AccountModel(TicketOfficeContext context)
public AccountModel(TicketOfficeContext context,
UserValidationService validationService,
PdfService pdfService)
{
this.context = context;
this.validationService = validationService;
this.pdfService = pdfService;
}
// User's tickets.
public List<Ticket> Tickets { get; set; } = null!;
// Will be set when user confirm ticket return.
[BindProperty(SupportsGet = true)]
public int ReturnTicketId { get; set; }
// Called when GET request is sent to the page. Checks if the session is
// valid then retrieves all user's tickets.
public ActionResult OnGet()
{
if (!ValidateSession())
if (!validationService.IsAuthorized(HttpContext))
{
return RedirectToPage("/Auth/Login");
}
Tickets = context.Ticket
.Where(t =>
@ -40,11 +45,11 @@ public class AccountModel : PageModel
}
// Called when user confirms ticket return.
public ActionResult OnGetReturnTicket()
public ActionResult OnGetReturnTicket(int returnTicketId)
{
OnGet();
Ticket? returnTicket = context.Ticket.Find(ReturnTicketId);
Ticket? returnTicket = context.Ticket.Find(returnTicketId);
if (returnTicket != null)
{
@ -56,8 +61,18 @@ public class AccountModel : PageModel
return NotFound();
}
private bool ValidateSession()
// Downloads ticket in PDF format
public ActionResult OnGetTicketPdf(int pdfTicketId)
{
return HttpContext.Session.GetInt32("UserId") != null;
OnGet();
Ticket? ticket = Tickets.Find(t => t.Id == pdfTicketId);
if (ticket == null)
{
return NotFound();
}
return pdfService.GetTicketPdf(ticket);
}
}

View File

@ -1,15 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TicketOffice.Services;
namespace TicketOffice.Pages.Auth;
public class IndexModel : PageModel
{
private readonly UserValidationService validationService;
public IndexModel(UserValidationService validationService)
{
this.validationService = validationService;
}
// Called when GET request is sent to the page. Determines what page
// user will be redirected to depending on his/her authorization status.
public ActionResult OnGet()
{
if (HttpContext.Session.GetInt32("UserId") != null)
if (validationService.IsAuthorized(HttpContext))
{
return RedirectToPage("/Auth/Account");
}

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
namespace TicketOffice.Pages.Auth;
@ -15,10 +16,13 @@ public class LoginModel : PageModel
public string PasswordValidationError = null!;
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
public LoginModel(TicketOfficeContext context)
public LoginModel(TicketOfficeContext context,
UserValidationService validationService)
{
this.context = context;
this.validationService = validationService;
}
// Object representing a user who wants to login.
@ -29,7 +33,7 @@ public class LoginModel : PageModel
// redirects to "Account" page if user already logged in.
public ActionResult OnGet()
{
if (ValidateSession())
if (validationService.IsAuthorized(HttpContext))
{
return RedirectToPage("/Auth/Account");
}
@ -47,6 +51,7 @@ public class LoginModel : PageModel
.FirstOrDefault(u => u.Email == User!.Email);
HttpContext.Session.SetInt32("UserId", user!.Id);
HttpContext.Session.SetInt32("IsManager", user!.IsManager ? 1 : 0);
return RedirectToPage("/Auth/Account");
}
@ -104,9 +109,4 @@ public class LoginModel : PageModel
return false;
}
}
private bool ValidateSession()
{
return HttpContext.Session.GetInt32("UserId") != null;
}
}

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
namespace TicketOffice.Pages.Auth;
@ -15,10 +16,13 @@ public class RegistrationModel : PageModel
public string PasswordValidationError = null!;
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
public RegistrationModel(TicketOfficeContext context)
public RegistrationModel(TicketOfficeContext context,
UserValidationService validationService)
{
this.context = context;
this.validationService = validationService;
}
[BindProperty]
@ -28,7 +32,7 @@ public class RegistrationModel : PageModel
// redirects to "Account" page if user already logged in.
public ActionResult OnGet()
{
if (ValidateSession())
if (validationService.IsAuthorized(HttpContext))
{
return RedirectToPage("/Auth/Account");
}
@ -120,9 +124,4 @@ public class RegistrationModel : PageModel
return true;
}
}
private bool ValidateSession()
{
return HttpContext.Session.GetInt32("UserId") != null;
}
}

View File

@ -0,0 +1,19 @@
@page
@model TicketOffice.Pages.Management.Index
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<div>
</div>
</body>
</html>

View File

@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TicketOffice.Services;
namespace TicketOffice.Pages.Management;
public class Index : PageModel
{
private readonly UserValidationService validationService;
public Index(UserValidationService validationService)
{
this.validationService = validationService;
}
public ActionResult OnGet()
{
if (!validationService.IsAuthorized(HttpContext))
{
return RedirectToPage("/Index");
}
else
{
return RedirectToPage("./Routes");
}
}
}

View File

@ -0,0 +1,116 @@
@page
@model TicketOffice.Pages.Management.Routes.CreateModel
@{
ViewData["Title"] = "Створити Маршрут";
Layout = "~/Pages/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="~/css/Management/Create.css"/>
<div class="wrapper">
<form method="post">
<div class="header">
Створити рейс
</div>
<div class="field">
<input class="field" type="number" placeholder="Номер автобуса" min="1" max="9999" autocomplete="off" asp-for="Route.Number"/>
<div class="validation-error"><span>@Model.NumberValidationError</span></div>
</div>
<div class="field">
<input class="field" type="number" placeholder="Ємність" min="5" max="40" autocomplete="off" asp-for="Route.Capacity"/>
<div class="validation-error"><span>@Model.CapacityValidationError</span></div>
</div>
@if (Model.CitiesCount != null)
{
<h2>Маршрут</h2>
@for (int i = 0; i < Model.CitiesCount; i++)
{
<div class="new-city">
<div class="city-name">
<input class="field-city" type="text" placeholder="Назва" autocomplete="off" asp-for="Route.Cities[i].Name">
<div class="validation-error"><span>@Model.NameValidationError[i]</span></div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час прибуття" autocomplete="off" asp-for="TimeStrings[i].ArrivalDate">
<div class="validation-error"><span></span>@Model.ArrivalTimeValidationError[i]</div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час відправлення" autocomplete="off" asp-for="TimeStrings[i].DepartureDate">
<div class="validation-error"><span>@Model.DepartureTimeValidationError[i]</span></div>
</div>
</div>
}
<input class="submit-btn" type="submit" value="Створити"/>
<input class="field-city" type="number" value="" hidden asp-for="CitiesCount">
}
else
{
<h2>Маршрут</h2>
<div class="field">
<input class="field-city" type="number" placeholder="Скільки міст буде у маршруті" autocomplete="off" asp-for="CitiesCount">
</div>
<br>
<input class="submit-btn" type="submit" value="Додати"/>
}
<div class="hint">
<a href="./Index" class="link">Назад до Списку</a>
</div>
</form>
</div>
<script>
let i = 1;
function AddCity() {
i++;
// TODO
// Validatio in java inserted code must be added
document.getElementById('add-city-btn').insertAdjacentHTML('beforebegin',
`<div class="new-city" id="new-city-${i}">
<div class="city-name">
<input class="field-city" type="text" placeholder="Назва" autocomplete="off" asp-for="Route.Cities[${i}].Name">
<div class="validation-error"><span></span></div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час прибуття" autocomplete="off" asp-for="TimeStrings[${i}].ArrivalDate">
<div class="validation-error"><span></span></div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час відправлення" autocomplete="off" asp-for="TimeStrings[${i}].DepartureDate">
<div class="validation-error"><span></span></div>
</div>
<a class="link-btn" id="close-city-${i}" onclick="RemoveCity(${i})">x</a>
</div>`)
document.getElementById(`close-city-${i - 1}`).remove();
}
function RemoveCity(j) {
document.getElementById(`new-city-${j}`).remove();
i--;
document.getElementById(`new-city-${i}`).insertAdjacentHTML('beforeend',
`<a class="link-btn" id="close-city-${i}" onclick="RemoveCity(${i})">x</a>`);
}
</script>

View File

@ -0,0 +1,294 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
using Route = TicketOffice.Models.Route;
namespace TicketOffice.Pages.Management.Routes;
public class CreateModel : PageModel
{
// Error massage displaying when route number validation failed.
public string NumberValidationError = null!;
// Error massage displaying when route capacity validation failed.
public string CapacityValidationError = null!;
// Array of error massages displaying when route name validation failed.
public string[] NameValidationError;
// Array of error massages displaying when cities
// departure time validation failed.
public string[] DepartureTimeValidationError;
// Array of error massages displaying when cities
// arrival time validation failed.
public string[] ArrivalTimeValidationError;
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
public CreateModel(TicketOfficeContext context,
UserValidationService validationService)
{
this.context = context;
this.validationService = validationService;
}
// Object representing that will be created.
[BindProperty]
public Route Route { get; set; }
// Object holding cities' arrival/departure dates.
[BindProperty]
public DateString[] TimeStrings { get; set; }
// Amount of cities to be added to the route
[BindProperty]
public int? CitiesCount { get; set; }
// Called when GET request is sent to the page.
public IActionResult OnGet()
{
if (!validationService.IsManager(HttpContext))
{
return RedirectToPage("/Index");
}
return Page();
}
// Called when POST request is sent to the page (when user tries to add new
// route). Validates input, creates new route in the database and
// redirects to "Management/Routes" page.
public ActionResult OnPost()
{
if (CitiesCount != null)
{
Route = new Route();
Route.Cities = new List<RouteCity>();
TimeStrings = new DateString[(int) CitiesCount];
for (int i = 0; i < CitiesCount; i++)
{
TimeStrings[i] = new DateString();
Route.Cities.Add(new RouteCity());
}
NameValidationError = new string[(int) CitiesCount];
DepartureTimeValidationError = new string[(int) CitiesCount];
ArrivalTimeValidationError = new string[(int) CitiesCount];
return Page();
}
NameValidationError = new string[Route.Cities.Count];
DepartureTimeValidationError = new string[Route.Cities.Count];
ArrivalTimeValidationError = new string[Route.Cities.Count];
InsertDatesIntoCities();
if (!ValidateInput())
{
return Page();
}
context.Route.Add(Route);
context.SaveChanges();
return RedirectToPage("./Index");
}
private void InsertDatesIntoCities()
{
for (int i = 0; i < Route.Cities.Count; i++)
{
try
{
Route.Cities[i].DepartureTime =
ConvertStringToDate(TimeStrings[i].DepartureDate);
}
catch(Exception e)
{
if (Route.Cities.Count > 2)
{
InitializeArray<string>(DepartureTimeValidationError,
Route.Cities.Count,
"");
}
DepartureTimeValidationError[i] = "Формат: dd.MM.yyyy, hh:mm";
}
try
{
Route.Cities[i].ArrivalTime =
ConvertStringToDate(TimeStrings[i].ArrivalDate);
}
catch(Exception e)
{
if (Route.Cities.Count > 2)
{
InitializeArray<string>(ArrivalTimeValidationError,
Route.Cities.Count,
"");
}
ArrivalTimeValidationError[i] = "Формат: dd.MM.yyyy, hh:mm";
}
}
DateTime? ConvertStringToDate(string dateStr)
{
if (String.IsNullOrWhiteSpace(dateStr) ||
String.IsNullOrEmpty(dateStr))
{
return null;
}
if (dateStr.Count(c => c == '.') != 2 &&
dateStr.Count(c => c == ':') != 2 &&
dateStr.Count(c => c == ',') != 1)
{
throw new ArgumentException("Invalid input format");
}
string[] date = dateStr.Split(",")[0].Split(".");
string[] time = dateStr.Split(",")[1].Split(":");
date.ToList().ForEach(s => s.Trim());
time.ToList().ForEach(s => s.Trim());
return new DateTime(
Int32.Parse(date[2]),
Int32.Parse(date[1]),
Int32.Parse(date[0]),
Int32.Parse(time[0]),
Int32.Parse(time[1]),
0);
}
}
private bool ValidateInput()
{
bool isValidNumber = ValidateNumber(Route.Number, out NumberValidationError);
bool isValidCapacity = ValidateCapacity(Route.Capacity, out CapacityValidationError);
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
InitializeArray<string>(NameValidationError,
Route.Cities.Count,
"");
}
ValidateName(Route.Cities[i].Name, out NameValidationError[i]);
}
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
InitializeArray<string>(DepartureTimeValidationError,
Route.Cities.Count,
"");
}
ValidateDate(Route.Cities[i].DepartureTime, out DepartureTimeValidationError[i]);
}
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
InitializeArray<string>(ArrivalTimeValidationError,
Route.Cities.Count,
"");
}
ValidateDate(Route.Cities[i].ArrivalTime, out ArrivalTimeValidationError[i]);
}
if (!isValidNumber || !isValidCapacity ||
NameValidationError.Any(e => e != "") ||
DepartureTimeValidationError.Any(e => e != "") ||
ArrivalTimeValidationError.Any(e => e != ""))
{
return false;
}
return true;
bool ValidateNumber(int number, out string validationError)
{
validationError = "";
if (number < 1 || number > 9999)
{
validationError = "Має бути в проміжку від 1 до 9999";
return false;
}
return true;
}
bool ValidateCapacity(int capacity, out string validationError)
{
validationError = "";
if (capacity < 1 || capacity > 40)
{
validationError = "Має бути в проміжку від 5 до 45";
return false;
}
return true;
}
bool ValidateName(string name, out string validationError)
{
validationError = "";
if (String.IsNullOrWhiteSpace(name) || String.IsNullOrEmpty(name))
{
validationError = "Поле має бути заповненим";
return false;
}
return true;
}
bool ValidateDate(DateTime? date, out string validationError)
{
validationError = "";
if (date == null)
{
return true;
}
if (date < DateTime.Today)
{
validationError = $"Має бути не раніше ніж {DateTime.Today.ToString(CultureInfo.GetCultureInfo("uk-UA")).Split(" ")[0]}";
return false;
}
return true;
}
}
private void InitializeArray<T>(T[] arr, int length, T initVal)
{
arr = new T[length];
for (int i = 0; i < length; i++)
{
arr[i] = initVal;
}
}
}

View File

@ -0,0 +1,60 @@
@page
@model TicketOffice.Pages.Management.Routes.EditModel
@{
ViewData["Title"] = $"Редагування Маршруту";
Layout = "~/Pages/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="~/css/Management/Create.css"/>
<div class="wrapper">
<form method="post">
<div class="header">
Редагування рейсу
</div>
<div class="field">
<input class="field" type="number" placeholder="Номер автобуса" min="1" max="9999" autocomplete="off" asp-for="Route.Number"/>
<div class="validation-error"><span>@Model.NumberValidationError</span></div>
</div>
<div class="field">
<input class="field" type="number" placeholder="Ємність" min="5" max="40" autocomplete="off" asp-for="Route.Capacity"/>
<div class="validation-error"><span>@Model.CapacityValidationError</span></div>
</div>
<h2>Маршрут</h2>
@for (int i = 0; i < @Model.Route.Cities.Count; i++)
{
<div class="new-city">
<div class="city-name">
<input class="field-city" type="text" placeholder="Назва" autocomplete="off" asp-for="Route.Cities[i].Name">
<div class="validation-error"><span>@Model.NameValidationError[i]</span></div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час прибуття" autocomplete="off" asp-for="TimeStrings[i].ArrivalDate">
<div class="validation-error"><span></span>@Model.ArrivalTimeValidationError[i]</div>
</div>
<div class="city-date">
<input class="field-city" type="text" placeholder="Дата й час відправлення" autocomplete="off" asp-for="TimeStrings[i].DepartureDate">
<div class="validation-error"><span>@Model.DepartureTimeValidationError[i]</span></div>
</div>
<input type="number" hidden value="@Model.Route.Cities[i].Id" asp-for="CityIds[i]">
</div>
}
<input class="submit-btn" type="submit" value="Зберегти"/>
<div class="hint">
<a href="./Index" class="link">Назад до Списку</a>
</div>
</form>
</div>

View File

@ -0,0 +1,360 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
using Route = TicketOffice.Models.Route;
namespace TicketOffice.Pages.Management.Routes;
public class EditModel : PageModel
{
// Error massage displaying when route number validation failed.
public string NumberValidationError = null!;
// Error massage displaying when route capacity validation failed.
public string CapacityValidationError = null!;
// Array of error massages displaying when route name validation failed.
public string[] NameValidationError;
// Array of error massages displaying when cities
// departure time validation failed.
public string[] DepartureTimeValidationError;
// Array of error massages displaying when cities
// arrival time validation failed.
public string[] ArrivalTimeValidationError;
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
public EditModel(TicketOfficeContext context,
UserValidationService validationService)
{
this.context = context;
this.validationService = validationService;
}
// Object representing that will be created.
[BindProperty]
public Route Route { get; set; }
// Object holding cities' arrival/departure dates.
[BindProperty]
public DateString[] TimeStrings { get; set; }
// Holds cities' ids between loading and saving
[BindProperty]
public int[] CityIds { get; set; }
// Called when GET request is sent to the page.
// Retrieves route.
public IActionResult OnGet(int? id)
{
if (!validationService.IsManager(HttpContext))
{
return RedirectToPage("/Index");
}
if (id == null)
{
return NotFound();
}
Route = context.Route.Where(m => m.Id == id)
.Include(r => r.Cities)
.First();
InitializeArrays();
TimeStrings = new DateString[Route.Cities.Count];
for (int i = 0; i < TimeStrings.Length; i++)
{
TimeStrings[i] = new DateString();
}
InsertDatesIntoStrings();
SaveCityIds();
if (Route == null)
{
return NotFound();
}
return Page();
}
// Called when POST request is sent to the page.
// Saves changes made to route.
public IActionResult OnPost(int? id)
{
InitializeArrays();
InsertDatesIntoCities();
LoadCityIds();
Route.Id = (int) id;
if (!ValidateInput())
{
return Page();
}
context.Attach(Route).State = EntityState.Modified;
foreach (var city in Route.Cities)
{
context.Attach(city).State = EntityState.Modified;
}
try
{
context.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
if (!RouteExists(Route.Id))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToPage("./Index");
}
private bool RouteExists(int id)
{
return context.Route.Any(e => e.Id == id);
}
private void InsertDatesIntoCities()
{
for (int i = 0; i < Route.Cities.Count; i++)
{
try
{
Route.Cities[i].DepartureTime =
ConvertStringToDate(TimeStrings[i].DepartureDate);
}
catch(Exception e)
{
if (Route.Cities.Count > 2)
{
DepartureTimeValidationError= InitializeArray<string>(
Route.Cities.Count, "");
}
DepartureTimeValidationError[i] = "Формат: dd.MM.yyyy, hh:mm";
}
try
{
Route.Cities[i].ArrivalTime =
ConvertStringToDate(TimeStrings[i].ArrivalDate);
}
catch(Exception e)
{
if (Route.Cities.Count > 2)
{
ArrivalTimeValidationError = InitializeArray<string>(
Route.Cities.Count, "");
}
ArrivalTimeValidationError[i] = "Формат: dd.MM.yyyy, hh:mm";
}
}
DateTime? ConvertStringToDate(string dateStr)
{
if (String.IsNullOrWhiteSpace(dateStr) ||
String.IsNullOrEmpty(dateStr))
{
return null;
}
string[] date = dateStr.Split(",")[0].Split(".");
string[] time = dateStr.Split(",")[1].Split(":");
date.ToList().ForEach(s => s.Trim());
time.ToList().ForEach(s => s.Trim());
return new DateTime(
Int32.Parse(date[2]),
Int32.Parse(date[1]),
Int32.Parse(date[0]),
Int32.Parse(time[0]),
Int32.Parse(time[1]),
0);
}
}
private void InsertDatesIntoStrings()
{
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities[i].DepartureTime != null)
{
TimeStrings[i].DepartureDate = Route.Cities[i].DepartureTime
.Value.ToString("dd.MM.yyyy, hh:mm");
}
if (Route.Cities[i].ArrivalTime != null)
{
TimeStrings[i].ArrivalDate = Route.Cities[i].ArrivalTime
.Value.ToString("dd.MM.yyyy, hh:mm");
}
}
}
// This method doesn't actually work, but stays here for representing
// an idea. It should be removed.
private void SaveCityIds()
{
CityIds = new int[Route.Cities.Count];
for (int i = 0; i < Route.Cities.Count; i++)
{
CityIds[i] = Route.Cities[i].Id;
}
}
private void LoadCityIds()
{
for (int i = 0; i < CityIds.Length; i++)
{
Route.Cities[i].Id = CityIds[i];
}
}
private bool ValidateInput()
{
bool isValidNumber = ValidateNumber(Route.Number, out NumberValidationError);
bool isValidCapacity = ValidateCapacity(Route.Capacity, out CapacityValidationError);
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
NameValidationError = InitializeArray<string>(
Route.Cities.Count, "");
}
ValidateName(Route.Cities[i].Name, out NameValidationError[i]);
}
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
DepartureTimeValidationError= InitializeArray<string>(
Route.Cities.Count, "");
}
ValidateDate(Route.Cities[i].DepartureTime, out DepartureTimeValidationError[i]);
}
for (int i = 0; i < Route.Cities.Count; i++)
{
if (Route.Cities.Count > 2)
{
ArrivalTimeValidationError = InitializeArray<string>(
Route.Cities.Count, "");
}
ValidateDate(Route.Cities[i].ArrivalTime, out ArrivalTimeValidationError[i]);
}
if (!isValidNumber || !isValidCapacity ||
NameValidationError.Any(e => e != "") ||
DepartureTimeValidationError.Any(e => e != "") ||
ArrivalTimeValidationError.Any(e => e != ""))
{
return false;
}
return true;
bool ValidateNumber(int number, out string validationError)
{
validationError = "";
if (number < 1 || number > 9999)
{
validationError = "Має бути в проміжку від 1 до 9999";
return false;
}
return true;
}
bool ValidateCapacity(int capacity, out string validationError)
{
validationError = "";
if (capacity < 1 || capacity > 40)
{
validationError = "Має бути в проміжку від 5 до 45";
return false;
}
return true;
}
bool ValidateName(string name, out string validationError)
{
validationError = "";
if (String.IsNullOrWhiteSpace(name) || String.IsNullOrEmpty(name))
{
validationError = "Поле має бути заповненим";
return false;
}
return true;
}
bool ValidateDate(DateTime? date, out string validationError)
{
validationError = "";
if (date == null)
{
return true;
}
if (date < DateTime.Today)
{
validationError = $"Має бути пізніше ніж {DateTime.Today.ToString(CultureInfo.GetCultureInfo("uk-UA"))}";
return false;
}
return true;
}
}
private T[] InitializeArray<T>(int length, T initVal)
{
T[] arr = new T[length];
for (int i = 0; i < length; i++)
{
arr[i] = initVal;
}
return arr;
}
private void InitializeArrays()
{
NameValidationError = InitializeArray<string>(Route.Cities.Count, "");
DepartureTimeValidationError = InitializeArray<string>(Route.Cities.Count, "");
ArrivalTimeValidationError = InitializeArray<string>(Route.Cities.Count, "");
}
}

View File

@ -0,0 +1,208 @@
@page
@model TicketOffice.Pages.Management.Routes.IndexModel
@{
ViewData["Title"] = "Управління Маршрутами";
Layout = "~/Pages/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="~/css/Management/Index.css"/>
<link rel="stylesheet" href="~/css/CityListPopup.css"/>
<link rel="stylesheet" href="~/css/InfoPopup.css">
<div class="wrapper">
<form class="search-block">
<div class="opt">
<div class="number">
<div class="title">
№ автобуса
</div>
<input class="search-input" type="number" min="1" max="9999" autocomplete="off" asp-for="Number">
</div>
<div class="station">
<div class="title">
Звідки
</div>
<input class="search-input" type="text" autocomplete="off" asp-for="From">
</div>
<div class="station">
<div class="title">
Куди
</div>
<input class="search-input" type="text" autocomplete="off" asp-for="To">
</div>
<div class="date">
<div class="title">
Дата рейсу
</div>
<input class="search-input" type="date" autocomplete="off" asp-for="Date">
</div>
<div class="search-btn">
<input type="submit" class="search-btn" value="Пошук"/>
</div>
</div>
</form>
<p>
<a class="link-btn" asp-page="Create">Створити новий</a>
</p>
@if (Model.Routes != null && Model.Routes.Count > 0)
{
<table>
<thead>
<tr>
<th>
№ автобуса
</th>
<th>
Ємність автобуса
</th>
<th>
Квитків продано
</th>
<th>
Дата відправлення
</th>
<th>
Дії
</th>
</tr>
</thead>
<tbody>
@foreach (var route in Model.Routes) {
<tr>
<td>
@Html.DisplayFor(modelItem => route.Number)
</td>
<td>
@Html.DisplayFor(modelItem => route.Capacity)
</td>
<td>
@route.Tickets.Count
</td>
<td>
@route.Cities.First().DepartureTime.Value.ToString("dd.MM.yyyy")
</td>
<td>
<a class="link-btn" onclick="document.getElementById('popup-city-list-@route.Id').style.display = 'inherit'">Маршрут</a>
<a class="link-btn" asp-page="./Edit" asp-route-id="@route.Id">Редагувати</a>
<a class="link-btn" onclick="document.getElementById ('popup-info-@route.Id').style.display = 'inherit'">Видалити</a>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<div class="search-error">
<p>Маршрутів не знайдено. Перевірте критерії пошуку або додайте новий рейс</p>
</div>
}
@if (Model.Routes != null)
{
foreach (var route in Model.Routes)
{
<div class="popup-container-city-list" id="popup-city-list-@route.Id">
<div class="popup-city-list">
<div class="popup-header-city-list">
Рейс №@route.Number
</div>
<div class="popup-body-city-list">
<table class="city-list">
<thead>
<tr class="tr-intermediate city-list">
<th class="th-route city-list">
Інформація
</th>
<th class="th-route city-list">
Місто
</th>
<th class="th-route city-list">
Час прибуття
</th>
<th class="th-route city-list">
Час відправлення
</th>
</tr>
</thead>
<tbody>
<tr class="tr-departure city-list">
<td class="td-route city-list">
Відправлення
</td>
<td class="td-route city-list">
@route.Cities.First().Name
</td>
<td class="td-route city-list">
-
</td>
<td class="td-route city-list">
@route.Cities.First().DepartureTime?.ToString("HH:mm")
</td>
</tr>
@for (int i = 1; i < route.Cities.Count - 1; i++)
{
<tr class="tr-intermediate city-list">
<td class="td-route city-list">
Проміжна станція
</td>
<td class="td-route city-list">
@route.Cities.ToList()[i].Name
</td>
<td class="td-route city-list">
@route.Cities.ToList()[i].ArrivalTime?.ToString("HH:mm")
</td>
<td class="td-route city-list">
@route.Cities.ToList()[i].DepartureTime?.ToString("HH:mm")
</td>
</tr>
}
<tr class="tr-arrival city-list">
<td class="td-route city-list">
Прибуття
</td>
<td class="td-route city-list">
@route.Cities.Last().Name
</td>
<td class="td-route city-list">
@route.Cities.Last().ArrivalTime?.ToString("HH:mm")
</td>
<td class="td-route city-list">
-
</td>
</tr>
</tbody>
</table>
</div>
<div class="popup-footer-city-list">
<a class="popup-footer-link-button-city-list" onclick="document.getElementById('popup-city-list-@route.Id').style.display = 'none'">Закрити</a>
</div>
</div>
</div>
<div class="popup-container-info" id="popup-info-@route.Id">
<div class="popup-info">
<div class="popup-info-header">Рейс № @route.Number</div>
<div class="popup-info-body">
Ви дійсно хочете видалити рейс?
</div>
<div class="popup-info-footer">
<a class="popup-info-footer-link-button"
onclick="document.getElementById('popup-info-@route.Id').style.display = 'none'">Відмінити</a>
<a class="popup-info-footer-link-button" asp-page-handler="DeleteRoute" asp-route-DeleteRouteId="@route.Id">Видалити</a>
</div>
</div>
</div>
}
}
</div>

View File

@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
using Route = TicketOffice.Models.Route;
namespace TicketOffice.Pages.Management.Routes
{
public class IndexModel : PageModel
{
private readonly TicketOfficeContext context;
private readonly UserValidationService validationService;
public IndexModel(TicketOfficeContext context,
UserValidationService validationService)
{
this.context = context;
this.validationService = validationService;
}
public List<Route>? Routes { get; set; }
// Search condition: route number.
[BindProperty(SupportsGet = true)]
public int? Number { get; set; }
// Search condition: departure city.
[BindProperty(SupportsGet = true)]
public string? From { get; set; }
// Search condition: arrival city.
[BindProperty(SupportsGet = true)]
public string? To { get; set; }
// Search condition: departure date.
[BindProperty(SupportsGet = true)]
public DateTime? Date { get; set; }
// Will be set when user confirm route deletion.
[BindProperty(SupportsGet = true)]
public int DeleteRouteId { get; set; }
// Called when GET request is sent to the page.
// Retrieves routes based on search conditions.
public ActionResult OnGet()
{
if (!validationService.IsManager(HttpContext))
{
return RedirectToPage("/Index");
}
RetrieveAllRoutes();
FilterRoutesByNumber();
FilterRoutesByFrom();
FilterRoutesByTo();
FilterRoutesByDate();
return Page();
}
// Called when user confirms route deletion.
public ActionResult OnGetDeleteRoute()
{
OnGet();
Route? deleteRoute = context.Route.Find(DeleteRouteId);
if (deleteRoute != null)
{
context.Remove(deleteRoute);
context.SaveChanges();
return RedirectToPage("./Index");
}
return NotFound();
}
private void RetrieveAllRoutes()
{
Routes = context.Route
.Include(r => r.Cities)
.Include(r => r.Tickets)
.ToList();
}
private void FilterRoutesByNumber()
{
if (Number == null || Number < 1)
{
return;
}
Routes.RemoveAll(r => r.Number != Number);
}
private void FilterRoutesByFrom()
{
if (String.IsNullOrWhiteSpace(From) || String.IsNullOrEmpty(From))
{
return;
}
Routes.RemoveAll(r => r.Cities.All(c => c.Name != From));
}
private void FilterRoutesByTo()
{
if (String.IsNullOrWhiteSpace(To) || String.IsNullOrEmpty(To))
{
return;
}
Routes.RemoveAll(r => r.Cities.All(c => c.Name != To));
}
private void FilterRoutesByDate()
{
if (Date == null)
{
return;
}
Routes.RemoveAll(r =>
r.Cities.All(c =>
c.DepartureTime?.Date != Date?.Date &&
c.ArrivalTime?.Date != Date?.Date));
}
}
}

View File

@ -197,7 +197,7 @@
<div class="popup-container-city-list" id="popup-city-list-@route.Id">
<div class="popup-city-list">
<div class="popup-header-city-list">
Автобус №@route.Number
Рейс № @route.Number
</div>
<div class="popup-body-city-list">
<table class="city-list">

View File

@ -335,7 +335,7 @@ public class IndexModel : PageModel
}
Routes!.RemoveAll(r =>
r.Cities.First().DepartureTime!.Value.DayOfYear != Date?.DayOfYear);
r.Cities.First().DepartureTime!.Value.Date != Date?.Date);
}
private void GetRoutes()

View File

@ -22,6 +22,11 @@
<a class="@(path == "/" ? "active" : "")" href="/">Головна</a>
<a class="@(path.Contains("routes") ? "active" : "")" href="/Routes">Пошук маршрутів</a>
<div class="topnav-right">
@if (Context.Session.GetInt32("IsManager") != null && Context.Session.GetInt32("IsManager") == 1)
{
<a class="@(path.Contains("management/routes") ? "active" : "")" href="/Management/Routes">Управління маршрутами</a>
}
@if (Context.Session.GetString("UserId") != null)
{
<a class="@(path.Contains("account") ? "active" : "")" href="/Auth/Account">Мої квитки</a>

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using TicketOffice.Data;
using TicketOffice.Models;
using TicketOffice.Services;
var builder = WebApplication.CreateBuilder(args);
@ -11,6 +12,9 @@ builder.Services.AddRazorPages()
builder.Services.AddDbContext<TicketOfficeContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("TicketOfficeContext")));
builder.Services.AddScoped<UserValidationService>();
builder.Services.AddScoped<PdfService>();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{

View File

@ -0,0 +1,114 @@
using System.Globalization;
using TicketOffice.Models;
using Microsoft.AspNetCore.Mvc;
using UglyToad.PdfPig.Content;
using UglyToad.PdfPig.Core;
using UglyToad.PdfPig.Writer;
namespace TicketOffice.Services;
public class PdfService
{
// Generates and returns PDF representation of some ticket
public FileStreamResult GetTicketPdf(Ticket ticket)
{
// Set culture info to be able to correctly convert date To String
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("uk-UA");
PdfDocumentBuilder builder = new PdfDocumentBuilder();
byte[] robotoBytes = File.ReadAllBytes(
$"{AppDomain.CurrentDomain.BaseDirectory}" +
$"/wwwroot/fonts/Roboto-Regular.ttf");
PdfDocumentBuilder.AddedFont roboto =
builder.AddTrueTypeFont(robotoBytes);
PdfPageBuilder page = builder.AddPage(PageSize.A4);
PdfPoint topCenter = new PdfPoint(
page.PageSize.Width / 2,
page.PageSize.Top - 25);
PdfPoint firstParagraphPoint =
new PdfPoint(15, page.PageSize.Top - 50);
int lineHeight = 25;
page.AddText(
$"auto.bus Квиток №{ticket.Id}",
16,
topCenter.Translate(-75,0),
roboto);
page.AddText(
$"Номер рейсу:",
14,
firstParagraphPoint,
roboto);
page.AddText(
$"№ {ticket.RouteId}",
14,
firstParagraphPoint.Translate(250, 0),
roboto);
page.AddText(
"Пасажир, місце:",
14,
firstParagraphPoint.Translate(0, -lineHeight),
roboto);
page.AddText(
$"{ticket.PassengerLastName} {ticket.PassengerFirstName}," +
$" № {ticket.PassengerPlace}",
14,
firstParagraphPoint.Translate(250, -lineHeight),
roboto);
page.AddText(
"Дата й місто відправлення:",
14,
firstParagraphPoint.Translate(0, 2 * -lineHeight),
roboto);
page.AddText(
$"{ticket.Cities.First().Name}," +
$" {ticket.Cities.First().DepartureTime?.ToString("f").Split(",")[0].ToLower()}," +
$" {ticket.Cities.First().DepartureTime?.ToString("dd.MM.yyyy")}," +
$" {ticket.Cities.First().DepartureTime?.ToString("HH:mm")}",
14,
firstParagraphPoint.Translate(250, 2 * -lineHeight),
roboto);
page.AddText(
"Дата й місто прибуття:",
14,
firstParagraphPoint.Translate(0, 3 * -lineHeight),
roboto);
page.AddText(
$"{ticket.Cities.Last().Name}," +
$" {ticket.Cities.Last().ArrivalTime?.ToString("f").Split(",")[0].ToLower()}," +
$" {ticket.Cities.Last().ArrivalTime?.ToString("dd.MM.yyyy")}," +
$" {ticket.Cities.Last().ArrivalTime?.ToString("HH:mm")}",
14,
firstParagraphPoint.Translate(250, 3 * -lineHeight),
roboto);
byte[] document = builder.Build();
//Saving the PDF to the MemoryStream
MemoryStream stream = new MemoryStream();
stream.Write(document);
//Set the position as '0'.
stream.Position = 0;
//Download the PDF document in the browser
FileStreamResult fileStreamResult =
new FileStreamResult(stream, "application/pdf");
fileStreamResult.FileDownloadName =
$"auto.bus Квиток №{ticket.Id}." +
$" Рейс №{ticket.RouteId}.pdf." +
$" {ticket.Cities.First().DepartureTime?.ToString("dd.MM.yyyy")}";
return fileStreamResult;
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
namespace TicketOffice.Services;
public class UserValidationService
{
// Determines if user is authiruzed
public bool IsAuthorized(HttpContext context)
{
return context.Session.GetInt32("UserId") != null;
}
// Determines if user has and administrative permissions
public bool IsManager(HttpContext context)
{
return context.Session.GetInt32("IsManager") != null;
}
}

View File

@ -9,6 +9,7 @@
<ItemGroup>
<Folder Include="Data" />
<Folder Include="Migrations" />
<Folder Include="Pages\Management\Routes" />
<Folder Include="wwwroot\db" />
</ItemGroup>
@ -27,6 +28,8 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.3" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.2" />
<PackageReference Include="PdfPig" Version="0.1.6" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -47,6 +47,7 @@ tr.tr-intermediate {
th.th-route, td.td-route {
height: 4rem;
line-height: 1.25rem;
font-size: 1rem;
text-align: center;
padding: 0 0.5rem;
}

View File

@ -0,0 +1,132 @@
html {
font-size: 16px;
min-height: 100%;
font-family: 'Roboto', sans-serif;
font-weight: 700;
background-color: #eaeef1;
}
body {
margin: 0;
}
.wrapper {
width: 55rem;
margin: 2.5rem auto;
padding: 3rem 0;
border-radius: 0.5rem;
box-shadow: 0 1px 2.4rem 0 #c3c9d0;
text-align: center;
}
.header {
margin-bottom: 1.5rem;
font-size: 1.75rem;
}
div.field {
width: 30rem;
margin: 0 auto;
}
div.new-city {
display: flex;
align-content: space-between;
margin: 0 1rem;
}
div.city-name {
width: 12.5rem;
margin: 0 auto;
}
div.city-date {
width: 17.5rem;
margin: 0 auto;
font-size: 1rem;
}
input {
font-family: 'Roboto', sans-serif;
font-size: 1.25rem;
font-weight: 700;
color: #262626;
line-height: 4.7rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
background: white;
border: 0.1rem solid #b8bfc7;
box-shadow: 0 1px 0 0 #fff;
border-radius: .3rem;
padding: 0 1.1rem;
text-align: left;
}
input:focus {
outline: 0;
border-color: #68b2dd;
background-color: #fff;
}
input.submit-btn {
color: #1d4965;
font-size: 1.4rem;
font-weight: 700;
line-height: 3rem;
padding: 0 1.5rem;
background: linear-gradient(0deg,#79b6db,#b3dbf2);
border: none;
border-radius: .3rem;
cursor: pointer;
width: fit-content;
}
.validation-error {
margin: 0.25rem 0 1rem 0;
color: #777a7e;
text-align: center;
width: 100%;
display: inline-block;
justify-content: center;
line-height: 1rem;
}
.hint {
margin-top: 1.5rem;
color: #777a7e;
}
input[type=submit]:hover {
opacity: 0.8;
}
.link {
color: #245c78;
text-decoration: none;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
.link-btn {
line-height: 2.5rem;
height: fit-content;
padding: 0 1rem;
margin: 0.25rem 0.25rem 0 0.25rem;
display: inline-block;
color: #1d4965;
font-size: 1rem;
font-weight: 500;
background: linear-gradient(0deg,#79b6db,#b3dbf2);
border: none;
border-radius: .3rem;
cursor: pointer;
text-decoration: none;
}
.link-btn:hover {
opacity: 0.8;
}

View File

@ -0,0 +1,188 @@
html {
font-size: 16px;
min-height: 100%;
font-family: 'Roboto', sans-serif;
background-color: #eaeef1;
}
body {
margin: 0;
}
.wrapper {
width: 78rem;
margin: 2.5rem auto;
}
div.control-pannel {
height: 4rem;
}
div.left {
width: 50%;
}
div.right {
width: 50%;
}
.search-block {
background: #eaeef1;
border-radius: 0.5rem;
box-shadow: 0 1px 2.4rem 0 #c3c9d0;
padding: 1.5rem;
margin: 1.5rem 0;
}
.station, .date, div.search-btn {
display: inline-block;
margin: 0 1.25rem;
width: 13.5rem;
}
.number {
display: inline-block;
margin: 0 1.25rem;
width: 7.5rem;
}
.title {
font-weight: 500;
color: #777a7e;
margin-bottom: .3rem;
}
input.search-input {
font-size: 1.5rem;
color: #262626;
font-weight: 500;
line-height: 4.7rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
background: #dfe3e5;
border: 1px solid #b8bfc7;
box-shadow: 0 1px 0 0 #fff;
border-radius: .3rem;
padding: 0 1.1rem;
text-align: left;
}
input.search-input:focus {
outline: 0;
border-color: #68b2dd;
background-color: #fff;
}
input.search-btn {
color: #1d4965;
font-size: 1.6rem;
font-weight: 700;
line-height: 3rem;
padding: 0 3rem;
display: inline-block;
background: linear-gradient(0deg,#79b6db,#b3dbf2);
border: none;
border-radius: .3rem;
cursor: pointer;
}
input.search-btn:hover {
opacity: 0.8;
}
.search-error {
background: #f1f2f4;
border: 1px solid #d7dce1;
box-shadow: 0 0 4px 0 rgba(195,201,208,.5);
font-weight: 500;
font-size: 1.5rem;
padding: 4rem 4rem;
text-align: center;
line-height: 3rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d7dce1;
}
th {
height: 5rem;
line-height: 1.6rem;
background: #e6e9ed;
border: 1px solid #d7dce1;
padding: 0 1rem;
font-size: 1rem;
text-align: start;
font-weight: 700;
color: #777a7e;
}
tr {
line-height: 5rem;
background-color: white;
}
tr:hover {
background-color: #dee9f4;
}
td {
padding: 0 1rem;
border: 1px solid #d7dce1;
text-align: center;
font-size: 1.5rem;
font-weight: 700;
line-height: 3.5rem;
}
td.num, td.capacity {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
.link-btn {
line-height: 2.5rem;
padding: 0 1rem;
display: inline-block;
color: #1d4965;
font-size: 1rem;
font-weight: 500;
background: linear-gradient(0deg,#79b6db,#b3dbf2);
border: none;
border-radius: .3rem;
cursor: pointer;
text-decoration: none;
}
.link-btn:hover {
opacity: 0.8;
}

Binary file not shown.