Compare commits

..

120 Commits

Author SHA1 Message Date
DESKTOP-GENO133\IvanPlex
9262131936 updated translation file. 2024-02-22 11:46:28 -07:00
Hargata Softworks
02c5302653 Update README.md 2024-02-22 11:20:19 -07:00
Hargata Softworks
fa73b61e3f Merge pull request #328 from hargata/Hargata/generic.editor
added functions for pinning notes.
2024-02-22 10:48:22 -07:00
DESKTOP-GENO133\IvanPlex
96ade10289 added functions for pinning notes. 2024-02-22 10:46:18 -07:00
Hargata Softworks
acf16a6c28 Merge pull request #327 from hargata/Hargata/generic.editor
added clear overrides
2024-02-22 10:12:13 -07:00
DESKTOP-GENO133\IvanPlex
dedf8fc2f7 added method to replenish supplies. 2024-02-22 10:11:26 -07:00
DESKTOP-GENO133\IvanPlex
7099652083 added clear overrides 2024-02-22 08:56:31 -07:00
Hargata Softworks
d0f9795e63 Merge pull request #326 from hargata/Hargata/clean.up.usings
code cleanup.
2024-02-21 22:58:00 -07:00
DESKTOP-GENO133\IvanPlex
0ffc469ac8 fixed missing end span tag. fixed deprecated js function. 2024-02-21 22:57:16 -07:00
DESKTOP-GENO133\IvanPlex
b5ea3c63a1 code cleanup. 2024-02-21 22:48:37 -07:00
Hargata Softworks
5901531ddf Merge pull request #325 from hargata/Hargata/bulk.selection
Hargata/bulk.selection
2024-02-21 22:33:19 -07:00
DESKTOP-GENO133\IvanPlex
814aa8d22b obsolete using 2024-02-21 22:30:25 -07:00
DESKTOP-GENO133\IvanPlex
d3aa55f987 Updated version number. 2024-02-21 22:29:47 -07:00
DESKTOP-GENO133\IvanPlex
8b7fb7ef0f added edit functionality for services, repairs, and upgrades 2024-02-21 22:28:25 -07:00
DESKTOP-GENO133\IvanPlex
0fab57d19c added bulk edit functionality. 2024-02-21 21:39:39 -07:00
DESKTOP-GENO133\IvanPlex
0d8822c496 reorganized folders. 2024-02-21 20:54:14 -07:00
Hargata Softworks
7235931f38 Merge pull request #322 from hargata/Hargata/bulk.selection
Bulk Operations.
2024-02-21 16:49:41 -07:00
DESKTOP-GENO133\IvanPlex
410f45c6c2 ssssss 2024-02-21 16:22:12 -07:00
DESKTOP-GENO133\IvanPlex
9a44d80be4 fixed compatibility on hybrid device. 2024-02-21 15:51:38 -07:00
DESKTOP-GENO133\IvanPlex
2f52ed64ba standardize experience across mobile devices 2024-02-21 15:05:19 -07:00
DESKTOP-GENO133\IvanPlex
dd68dec05c moved stuff around and added duplicate option. 2024-02-21 13:45:08 -07:00
DESKTOP-GENO133\IvanPlex
a8bc9f90e1 display number of records manipulated. 2024-02-21 12:57:09 -07:00
DESKTOP-GENO133\IvanPlex
8759216dd6 only select rows that are visible. 2024-02-21 12:50:11 -07:00
DESKTOP-GENO133\IvanPlex
0851e0a222 made things mobile friendly. 2024-02-21 12:38:50 -07:00
DESKTOP-GENO133\IvanPlex
40f1f1380e added select all to context menu for mobile friendliness. 2024-02-21 11:53:00 -07:00
DESKTOP-GENO133\IvanPlex
2a5ff7a911 added ability to select reminders from tax modal. 2024-02-21 10:32:25 -07:00
DESKTOP-GENO133\IvanPlex
0590f991d2 added shared method to push back recurring reminders. 2024-02-21 10:10:28 -07:00
DESKTOP-GENO133\IvanPlex
c6a96df3e9 updated readme 2024-02-20 21:21:20 -07:00
DESKTOP-GENO133\IvanPlex
0088c74b20 added ability to select rows when ctrl key is held. 2024-02-20 20:23:47 -07:00
DESKTOP-T0O5CDB\DESK-555BD
6af0d8b88e added functionality to delete records in bulk. 2024-02-19 22:32:07 -07:00
DESKTOP-T0O5CDB\DESK-555BD
5204a71b00 added option to bulk move records across service, upgrade, and repair. 2024-02-19 21:57:11 -07:00
DESKTOP-T0O5CDB\DESK-555BD
8b2866b89b added ability to select bulk data. 2024-02-19 16:48:12 -07:00
Hargata Softworks
65f638f336 Merge pull request #319 from hargata/Hargata/csv.odo.insert
Hargata/csv.odo.insert
2024-02-19 14:00:52 -07:00
DESKTOP-T0O5CDB\DESK-555BD
f92e95e01c Merge branch 'main' into Hargata/report.usability 2024-02-19 13:59:04 -07:00
DESKTOP-T0O5CDB\DESK-555BD
2aeae70060 Enabled Auto Odometer CSV when importing via CSV. 2024-02-19 13:58:23 -07:00
Hargata Softworks
98b1cd9bc5 Merge pull request #315 from hargata/Hargata/linked.planner.to.reminder
check if reminder still exists.
2024-02-18 15:04:45 -07:00
DESKTOP-GENO133\IvanPlex
a02684f921 check if reminder still exists. 2024-02-18 15:04:02 -07:00
Hargata Softworks
13ddc6309e Merge pull request #314 from hargata/Hargata/linked.planner.to.reminder
Updated version number
2024-02-18 14:42:42 -07:00
DESKTOP-GENO133\IvanPlex
2b2fd3ae28 Updated version number 2024-02-18 14:42:05 -07:00
Hargata Softworks
2dd3dc1718 Merge pull request #313 from hargata/Hargata/linked.planner.to.reminder
added ability to create plans from reminders.
2024-02-18 14:23:39 -07:00
DESKTOP-GENO133\IvanPlex
fe1b96d58c updated translation 2024-02-18 14:22:43 -07:00
DESKTOP-GENO133\IvanPlex
8dfee0fd12 added ability to create plans from reminders. 2024-02-18 14:20:20 -07:00
Hargata Softworks
275b60aa14 Merge pull request #310 from hargata/Hargata/supply.usage.history
Supply Usage History
2024-02-18 13:16:46 -07:00
DESKTOP-GENO133\IvanPlex
dd14b71e68 added front end to show used supplies. 2024-02-18 11:32:04 -07:00
DESKTOP-GENO133\IvanPlex
849171c4df moved function to shared js. 2024-02-17 21:43:19 -07:00
DESKTOP-GENO133\IvanPlex
2183c77606 added button to show supply requisition history. 2024-02-17 19:13:09 -07:00
Hargata Softworks
bedf7bad34 Merge pull request #306 from hargata/Hargata/report.usability
Improved Usability
2024-02-17 17:13:41 -07:00
DESKTOP-GENO133\IvanPlex
802c7923d5 added supply usage history 2024-02-17 17:07:31 -07:00
DESKTOP-GENO133\IvanPlex
95c8cd19f8 Updated error message 2024-02-17 10:53:29 -07:00
DESKTOP-GENO133\IvanPlex
e700a5f1c2 functionality to duplicate collaborators across vehicles. 2024-02-17 10:51:39 -07:00
DESKTOP-T0O5CDB\DESK-555BD
7fd7f7e29d added select all option to reports page. 2024-02-16 15:50:52 -07:00
DESKTOP-T0O5CDB\DESK-555BD
902ddd0269 improve usability on reports dropdown 2024-02-16 15:29:06 -07:00
Hargata Softworks
0a6424f8c0 Merge pull request #305 from hargata/Hargata/user.provisioning
revert
2024-02-16 14:07:52 -07:00
DESKTOP-T0O5CDB\DESK-555BD
3a36c624ba revert 2024-02-16 14:07:22 -07:00
Hargata Softworks
1329beb808 Merge pull request #304 from hargata/Hargata/user.provisioning
attempt to fix docker build.
2024-02-16 14:05:40 -07:00
DESKTOP-T0O5CDB\DESK-555BD
0e8ef0180f attempt to fix docker build. 2024-02-16 14:05:01 -07:00
Hargata Softworks
660f089035 Merge pull request #303 from hargata/Hargata/user.provisioning
Hargata/user.provisioning
2024-02-16 13:57:52 -07:00
DESKTOP-T0O5CDB\DESK-555BD
04f90ae6a8 Added Distance Traveled Chart 2024-02-16 13:55:22 -07:00
DESKTOP-T0O5CDB\DESK-555BD
30b4a73fef made datepicker honor locale's week start day 2024-02-16 11:58:23 -07:00
DESKTOP-T0O5CDB\DESK-555BD
b7158f3bf0 automatically log open id user in after they've registered. 2024-02-16 11:06:49 -07:00
DESKTOP-T0O5CDB\DESK-555BD
f46bbe9963 Allow OpenID Users to sign up with a token. 2024-02-16 10:48:20 -07:00
Hargata Softworks
9f73068a9e Merge pull request #301 from hargata/Hargata/oidc.auth
Updated app version.
2024-02-15 20:12:23 -07:00
DESKTOP-GENO133\IvanPlex
779b18802b Updated app version. 2024-02-15 20:11:39 -07:00
Hargata Softworks
dce9acf47f Merge pull request #300 from hargata/Hargata/oidc.auth
Hargata/OIDC.auth
2024-02-15 20:08:54 -07:00
DESKTOP-GENO133\IvanPlex
c9a925f548 Updated section name 2024-02-15 19:10:38 -07:00
DESKTOP-GENO133\IvanPlex
4eeec7887a Updated wording 2024-02-15 19:09:50 -07:00
Hargata Softworks
156c781bd7 Merge pull request #297 from hargata/Hargata/bugfix
Fixed ExtraFields upsert query.
2024-02-15 19:07:14 -07:00
DESKTOP-T0O5CDB\DESK-555BD
296cf92022 added error handling for users not registered with LubeLogger 2024-02-15 19:02:11 -07:00
DESKTOP-T0O5CDB\DESK-555BD
d5f0e57c3b Added OpenID login. 2024-02-15 18:57:22 -07:00
DESKTOP-GENO133\IvanPlex
86ba6200fc Fixed ExtraFields upsert query. 2024-02-15 09:26:01 -07:00
Hargata Softworks
ac4ea07319 Merge pull request #291 from hargata/Hargata/further.improvements
styling changes.
2024-02-14 12:22:03 -07:00
DESKTOP-T0O5CDB\DESK-555BD
6d380de603 styling changes. 2024-02-14 12:21:39 -07:00
Hargata Softworks
e789bc6925 Merge pull request #290 from hargata/Hargata/further.improvements
server timezone offset.
2024-02-14 12:15:05 -07:00
DESKTOP-T0O5CDB\DESK-555BD
f7481be91c Updated version. 2024-02-14 12:14:10 -07:00
DESKTOP-T0O5CDB\DESK-555BD
a8151bcf0e server timezone offset. 2024-02-14 12:12:49 -07:00
Hargata Softworks
0b6a6787bb Merge pull request #289 from hargata/Hargata/further.improvements
Further Improvements
2024-02-14 11:02:59 -07:00
DESKTOP-T0O5CDB\DESK-555BD
7302982366 removed empty script tag. 2024-02-14 11:01:08 -07:00
DESKTOP-T0O5CDB\DESK-555BD
19b7dfc90a added calendar view modal 2024-02-14 10:56:33 -07:00
DESKTOP-T0O5CDB\DESK-555BD
29825a6ad6 Merge branch 'main' into Hargata/further.improvements
# Conflicts:
#	wwwroot/js/garage.js
2024-02-14 09:21:16 -07:00
DESKTOP-T0O5CDB\DESK-555BD
f6493f0496 Further improvements to calendar page. 2024-02-14 09:18:47 -07:00
Hargata Softworks
c2d61eecc0 Merge pull request #285 from hargata/Hargata/encode.html
Encode HTML Inputs.
2024-02-13 17:46:13 -07:00
DESKTOP-GENO133\IvanPlex
2a4354c52e Encode HTML Inputs. 2024-02-13 17:45:41 -07:00
Hargata Softworks
e7dc4f67f5 Merge pull request #284 from hargata/Hargata/calendar.view
Lock down contoller methods for extra fields.
2024-02-13 16:48:48 -07:00
DESKTOP-T0O5CDB\DESK-555BD
8584d2cf9c Lock down contoller methods for extra fields. 2024-02-13 16:48:24 -07:00
Hargata Softworks
92b9c6953c Merge pull request #283 from hargata/Hargata/calendar.view
fixed bug for records with deleted views.
2024-02-13 16:17:12 -07:00
DESKTOP-T0O5CDB\DESK-555BD
ac1c2ca7fe fixed bug for records with deleted views. 2024-02-13 16:16:49 -07:00
Hargata Softworks
f05ca7f1f8 Merge pull request #282 from hargata/Hargata/calendar.view
Hargata/calendar.view
2024-02-13 16:03:31 -07:00
DESKTOP-T0O5CDB\DESK-555BD
ecef1c8640 cleaner code. 2024-02-13 16:02:01 -07:00
DESKTOP-T0O5CDB\DESK-555BD
43d58a4b7e Fixed bug with styling. 2024-02-13 15:49:50 -07:00
DESKTOP-T0O5CDB\DESK-555BD
8293b376a2 Added a Calendar View 2024-02-13 15:36:25 -07:00
Hargata Softworks
c243cdd54c Merge pull request #276 from hargata/Hargata/extra.fields
Extra Fields
2024-02-12 20:59:53 -07:00
Hargata Softworks
0383257356 Merge pull request #278 from hargata/Hargata/numeric.odo
Numeric keyboard for odometer field.
2024-02-12 20:56:33 -07:00
DESKTOP-GENO133\IvanPlex
73e8731bd4 Numeric keyboard for odometer field. 2024-02-12 20:56:03 -07:00
DESKTOP-GENO133\IvanPlex
b0e82889e0 updated translation, fixed postgres method for extra field insert. 2024-02-12 20:28:36 -07:00
Hargata Softworks
8e28116371 Merge pull request #277 from hargata/Hargata/documentation.static
Static File Documentation
2024-02-12 19:29:06 -07:00
DESKTOP-GENO133\IvanPlex
ed717af91c further optimization. 2024-02-12 19:26:11 -07:00
DESKTOP-GENO133\IvanPlex
7ae5a982e9 updated this thang. 2024-02-12 18:31:11 -07:00
DESKTOP-GENO133\IvanPlex
2e3f1e6763 updated translation file. 2024-02-12 18:30:44 -07:00
DESKTOP-GENO133\IvanPlex
4a2e02afc8 fixed bug where required fields don't persist. 2024-02-12 18:30:03 -07:00
DESKTOP-GENO133\IvanPlex
9c53850faa added front end for displaying extra fields. 2024-02-12 17:39:09 -07:00
DESKTOP-GENO133\IvanPlex
695b15faed added UI to add/edit/delete extra fields. 2024-02-12 17:36:13 -07:00
DESKTOP-GENO133\IvanPlex
33aeaf9825 front end logic for saving extra fields. 2024-02-12 17:32:08 -07:00
DESKTOP-GENO133\IvanPlex
dc3608524a fomatting. 2024-02-12 15:04:32 -07:00
DESKTOP-GENO133\IvanPlex
6996814888 added backend for storing extra fields. 2024-02-12 15:04:02 -07:00
DESKTOP-GENO133\IvanPlex
9bbaf49ade Static documentation cloned from otterwiki 2024-02-12 13:59:53 -07:00
DESKTOP-GENO133\IvanPlex
db737df712 removed empty extra fields from being added to object. 2024-02-12 09:15:05 -07:00
DESKTOP-GENO133\IvanPlex
bae1839c06 added JS to retrieve and validate extra fields. 2024-02-12 09:05:59 -07:00
DESKTOP-GENO133\IvanPlex
7e70f5108f changed dictionary to class object so we can enforce field requirement. 2024-02-12 03:27:26 -07:00
DESKTOP-GENO133\IvanPlex
c5c0a6900b added extra fields attribute to object classes. 2024-02-11 19:08:27 -07:00
Hargata Softworks
50b88dd7a1 Merge pull request #275 from hargata/Hargata/supplies.improvement
updated version number and fixed labels not updating in supplies page
2024-02-11 18:36:33 -07:00
DESKTOP-GENO133\IvanPlex
e44eb991f5 updated version number and fixed labels not updating in supplies page 2024-02-11 18:14:34 -07:00
Hargata Softworks
39d0d346ad Merge pull request #274 from hargata/Hargata/supplies.improvement
fixed supply requisition when used with european units.
2024-02-11 14:41:31 -07:00
DESKTOP-GENO133\IvanPlex
142865f1ce fixed supply requisition when used with european units. 2024-02-11 14:40:47 -07:00
Hargata Softworks
2c18755a0f Merge pull request #273 from hargata/Hargata/supplies.improvement
Supplies Improvement
2024-02-11 13:08:08 -07:00
DESKTOP-GENO133\IvanPlex
d3be77bf09 fixed div name, 2024-02-11 12:47:43 -07:00
DESKTOP-GENO133\IvanPlex
bcafd9e97e added tags in supplies requisition modal. 2024-02-11 12:46:34 -07:00
DESKTOP-GENO133\IvanPlex
f581e55842 added tags to general supply record tab. 2024-02-11 09:48:54 -07:00
DESKTOP-GENO133\IvanPlex
dfe4e4f1ab added check for missing supplies 2024-02-11 09:24:48 -07:00
Hargata Softworks
080e2a1667 Merge pull request #269 from hargata/Hargata/num.input
added attributes to prompt numeric inputs without actually limiting t…
2024-02-10 22:53:57 -07:00
DESKTOP-GENO133\IvanPlex
01cbee00fd added attributes to prompt numeric inputs without actually limiting the user to numbers. 2024-02-10 22:52:22 -07:00
119 changed files with 3642 additions and 396 deletions

View File

@@ -13,7 +13,8 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="Npgsql" Version="8.0.1" />
<PackageReference Include="Npgsql" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
</ItemGroup>
</Project>

View File

@@ -3,8 +3,6 @@ using CarCareTracker.Logic;
using CarCareTracker.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Net.Mail;
namespace CarCareTracker.Controllers
{

View File

@@ -1,12 +1,5 @@
using CarCareTracker.External.Interfaces;
using CarCareTracker.Models;
using LiteDB;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using static System.Net.Mime.MediaTypeNames;
using System.Drawing;
using System.Linq.Expressions;
using Microsoft.Extensions.Logging;
using CarCareTracker.Helper;
using Microsoft.AspNetCore.Authorization;

View File

@@ -17,18 +17,26 @@ namespace CarCareTracker.Controllers
private readonly IUserLogic _userLogic;
private readonly IFileHelper _fileHelper;
private readonly IConfigHelper _config;
public HomeController(ILogger<HomeController> logger,
private readonly IExtraFieldDataAccess _extraFieldDataAccess;
private readonly IReminderRecordDataAccess _reminderRecordDataAccess;
private readonly IReminderHelper _reminderHelper;
public HomeController(ILogger<HomeController> logger,
IVehicleDataAccess dataAccess,
IUserLogic userLogic,
IConfigHelper configuration,
IFileHelper fileHelper)
IFileHelper fileHelper,
IExtraFieldDataAccess extraFieldDataAccess,
IReminderRecordDataAccess reminderRecordDataAccess,
IReminderHelper reminderHelper)
{
_logger = logger;
_dataAccess = dataAccess;
_config = configuration;
_userLogic = userLogic;
_fileHelper = fileHelper;
_extraFieldDataAccess = extraFieldDataAccess;
_reminderRecordDataAccess = reminderRecordDataAccess;
_reminderHelper = reminderHelper;
}
private int GetUserID()
{
@@ -47,6 +55,35 @@ namespace CarCareTracker.Controllers
}
return PartialView("_GarageDisplay", vehiclesStored);
}
public IActionResult Calendar()
{
var vehiclesStored = _dataAccess.GetVehicles();
if (!User.IsInRole(nameof(UserData.IsRootUser)))
{
vehiclesStored = _userLogic.FilterUserVehicles(vehiclesStored, GetUserID());
}
List<ReminderRecordViewModel> reminders = new List<ReminderRecordViewModel>();
foreach(Vehicle vehicle in vehiclesStored)
{
var vehicleReminders = _reminderRecordDataAccess.GetReminderRecordsByVehicleId(vehicle.Id);
vehicleReminders.RemoveAll(x => x.Metric == ReminderMetric.Odometer);
//we don't care about mileages so we can basically fake the current vehicle mileage.
if (vehicleReminders.Any())
{
var maxMileage = vehicleReminders.Max(x => x.Mileage) + 1000;
var reminderUrgency = _reminderHelper.GetReminderRecordViewModels(vehicleReminders, maxMileage, DateTime.Now);
reminderUrgency = reminderUrgency.Select(x => new ReminderRecordViewModel { Id = x.Id, Date = x.Date, Urgency = x.Urgency, Description = $"{vehicle.Year} {vehicle.Make} {vehicle.Model} #{vehicle.LicensePlate} - {x.Description}" }).ToList();
reminders.AddRange(reminderUrgency);
}
}
return PartialView("_Calendar", reminders);
}
public IActionResult ViewCalendarReminder(int reminderId)
{
var reminder = _reminderRecordDataAccess.GetReminderRecordById(reminderId);
var reminderUrgency = _reminderHelper.GetReminderRecordViewModels(new List<ReminderRecord> { reminder }, reminder.Mileage + 1000, DateTime.Now).FirstOrDefault();
return PartialView("_ReminderRecordCalendarModal", reminderUrgency);
}
public IActionResult Settings()
{
var userConfig = _config.GetUserConfig(User);
@@ -68,7 +105,30 @@ namespace CarCareTracker.Controllers
{
return View();
}
[Authorize(Roles = nameof(UserData.IsRootUser))]
public IActionResult GetExtraFieldsModal(int importMode = 0)
{
var recordExtraFields = _extraFieldDataAccess.GetExtraFieldsById(importMode);
if (recordExtraFields.Id != importMode)
{
recordExtraFields.Id = importMode;
}
return PartialView("_ExtraFields", recordExtraFields);
}
[Authorize(Roles = nameof(UserData.IsRootUser))]
public IActionResult UpdateExtraFields(RecordExtraField record)
{
try
{
var result = _extraFieldDataAccess.SaveExtraFields(record);
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
}
var recordExtraFields = _extraFieldDataAccess.GetExtraFieldsById(record.Id);
return PartialView("_ExtraFields", recordExtraFields);
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{

View File

@@ -4,9 +4,7 @@ using CarCareTracker.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
namespace CarCareTracker.Controllers
@@ -15,16 +13,19 @@ namespace CarCareTracker.Controllers
{
private IDataProtector _dataProtector;
private ILoginLogic _loginLogic;
private IConfigHelper _config;
private readonly ILogger<LoginController> _logger;
public LoginController(
ILogger<LoginController> logger,
IDataProtectionProvider securityProvider,
ILoginLogic loginLogic
)
ILoginLogic loginLogic,
IConfigHelper config
)
{
_dataProtector = securityProvider.CreateProtector("login");
_logger = logger;
_loginLogic = loginLogic;
_config = config;
}
public IActionResult Index(string redirectURL = "")
{
@@ -42,6 +43,70 @@ namespace CarCareTracker.Controllers
{
return View();
}
public IActionResult GetRemoteLoginLink()
{
var remoteAuthURL = _config.GetOpenIDConfig().RemoteAuthURL;
return Json(remoteAuthURL);
}
public async Task<IActionResult> RemoteAuth(string code)
{
try
{
if (!string.IsNullOrWhiteSpace(code))
{
//received code from OIDC provider
//create http client to retrieve user token from OIDC
var httpClient = new HttpClient();
var openIdConfig = _config.GetOpenIDConfig();
var httpParams = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("client_id", openIdConfig.ClientId),
new KeyValuePair<string, string>("client_secret", openIdConfig.ClientSecret),
new KeyValuePair<string, string>("redirect_uri", openIdConfig.RedirectURL)
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, openIdConfig.TokenURL)
{
Content = new FormUrlEncodedContent(httpParams)
};
var tokenResult = await httpClient.SendAsync(httpRequest).Result.Content.ReadAsStringAsync();
var userJwt = JsonSerializer.Deserialize<OpenIDResult>(tokenResult)?.id_token ?? string.Empty;
if (!string.IsNullOrWhiteSpace(userJwt))
{
//validate JWT token
var tokenParser = new JwtSecurityTokenHandler();
var parsedToken = tokenParser.ReadJwtToken(userJwt);
var userEmailAddress = parsedToken.Claims.First(x => x.Type == "email").Value;
if (!string.IsNullOrWhiteSpace(userEmailAddress))
{
var userData = _loginLogic.ValidateOpenIDUser(new LoginModel() { EmailAddress = userEmailAddress });
if (userData.Id != default)
{
AuthCookie authCookie = new AuthCookie
{
UserData = userData,
ExpiresOn = DateTime.Now.AddDays(1)
};
var serializedCookie = JsonSerializer.Serialize(authCookie);
var encryptedCookie = _dataProtector.Protect(serializedCookie);
Response.Cookies.Append("ACCESS_TOKEN", encryptedCookie, new CookieOptions { Expires = new DateTimeOffset(authCookie.ExpiresOn) });
return new RedirectResult("/Home");
} else
{
_logger.LogInformation($"User {userEmailAddress} tried to login via OpenID but is not a registered user in LubeLogger.");
return View("OpenIDRegistration", model: userEmailAddress);
}
}
}
}
} catch (Exception ex)
{
_logger.LogError(ex.Message);
return new RedirectResult("/Login");
}
return new RedirectResult("/Login");
}
[HttpPost]
public IActionResult Login(LoginModel credentials)
{
@@ -81,6 +146,27 @@ namespace CarCareTracker.Controllers
return Json(result);
}
[HttpPost]
public IActionResult RegisterOpenIdUser(LoginModel credentials)
{
var result = _loginLogic.RegisterOpenIdUser(credentials);
if (result.Success)
{
var userData = _loginLogic.ValidateOpenIDUser(new LoginModel() { EmailAddress = credentials.EmailAddress });
if (userData.Id != default)
{
AuthCookie authCookie = new AuthCookie
{
UserData = userData,
ExpiresOn = DateTime.Now.AddDays(1)
};
var serializedCookie = JsonSerializer.Serialize(authCookie);
var encryptedCookie = _dataProtector.Protect(serializedCookie);
Response.Cookies.Append("ACCESS_TOKEN", encryptedCookie, new CookieOptions { Expires = new DateTimeOffset(authCookie.ExpiresOn) });
}
}
return Json(result);
}
[HttpPost]
public IActionResult RequestResetPassword(LoginModel credentials)
{
var result = _loginLogic.RequestResetPassword(credentials);

View File

@@ -1,13 +1,10 @@
using CarCareTracker.External.Implementations;
using CarCareTracker.Helper;
using CarCareTracker.Helper;
using CarCareTracker.Models;
using LiteDB;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using System.Data.Common;
using System.IO.Compression;
using System.Xml.Linq;
namespace CarCareTracker.Controllers
{
@@ -52,7 +49,8 @@ namespace CarCareTracker.Controllers
"CREATE TABLE IF NOT EXISTS app.userrecords (id INT GENERATED BY DEFAULT AS IDENTITY primary key, username TEXT not null, emailaddress TEXT not null, password TEXT not null, isadmin BOOLEAN)",
"CREATE TABLE IF NOT EXISTS app.tokenrecords (id INT GENERATED BY DEFAULT AS IDENTITY primary key, body TEXT not null, emailaddress TEXT not null)",
"CREATE TABLE IF NOT EXISTS app.userconfigrecords (id INT primary key, data jsonb not null)",
"CREATE TABLE IF NOT EXISTS app.useraccessrecords (userId INT, vehicleId INT, PRIMARY KEY(userId, vehicleId))"
"CREATE TABLE IF NOT EXISTS app.useraccessrecords (userId INT, vehicleId INT, PRIMARY KEY(userId, vehicleId))",
"CREATE TABLE IF NOT EXISTS app.extrafields (id INT primary key, data jsonb not null)"
};
foreach(string cmd in cmds)
{
@@ -97,6 +95,8 @@ namespace CarCareTracker.Controllers
var tokenrecords = new List<Token>();
var userconfigrecords = new List<UserConfigData>();
var useraccessrecords = new List<UserAccess>();
var extrafields = new List<RecordExtraField>();
#region "Part1"
string cmd = $"SELECT data FROM app.vehicles";
using (var ctext = pgDataSource.CreateCommand(cmd))
@@ -396,6 +396,25 @@ namespace CarCareTracker.Controllers
};
}
#endregion
#region "Part5"
cmd = $"SELECT data FROM app.extrafields";
using (var ctext = pgDataSource.CreateCommand(cmd))
{
using (NpgsqlDataReader reader = ctext.ExecuteReader())
while (reader.Read())
{
extrafields.Add(System.Text.Json.JsonSerializer.Deserialize<RecordExtraField>(reader["data"] as string));
}
}
foreach (var record in extrafields)
{
using (var db = new LiteDatabase(fullFileName))
{
var table = db.GetCollection<RecordExtraField>("extrafields");
table.Upsert(record);
};
}
#endregion
var destFilePath = $"{fullFolderPath}.zip";
ZipFile.CreateFromDirectory(fullFolderPath, destFilePath);
return Json(new OperationResponse { Success = true, Message = $"/{tempFolder}.zip" });
@@ -441,6 +460,8 @@ namespace CarCareTracker.Controllers
var tokenrecords = new List<Token>();
var userconfigrecords = new List<UserConfigData>();
var useraccessrecords = new List<UserAccess>();
var extrafields = new List<RecordExtraField>();
#region "Part1"
using (var db = new LiteDatabase(fullFileName))
{
@@ -704,6 +725,23 @@ namespace CarCareTracker.Controllers
}
}
#endregion
#region "Part5"
using (var db = new LiteDatabase(fullFileName))
{
var table = db.GetCollection<RecordExtraField>("extrafields");
extrafields = table.FindAll().ToList();
};
foreach (var record in extrafields)
{
string cmd = $"INSERT INTO app.extrafields (id, data) VALUES(@id, CAST(@data AS jsonb))";
using (var ctext = pgDataSource.CreateCommand(cmd))
{
ctext.Parameters.AddWithValue("id", record.Id);
ctext.Parameters.AddWithValue("data", System.Text.Json.JsonSerializer.Serialize(record));
ctext.ExecuteNonQuery();
}
}
#endregion
return Json(new OperationResponse { Success = true, Message = "Data Imported Successfully" });
}
catch (Exception ex)

View File

@@ -36,6 +36,7 @@ namespace CarCareTracker.Controllers
private readonly IReminderHelper _reminderHelper;
private readonly IReportHelper _reportHelper;
private readonly IUserLogic _userLogic;
private readonly IExtraFieldDataAccess _extraFieldDataAccess;
public VehicleController(ILogger<VehicleController> logger,
IFileHelper fileHelper,
@@ -54,6 +55,7 @@ namespace CarCareTracker.Controllers
IPlanRecordDataAccess planRecordDataAccess,
IPlanRecordTemplateDataAccess planRecordTemplateDataAccess,
IOdometerRecordDataAccess odometerRecordDataAccess,
IExtraFieldDataAccess extraFieldDataAccess,
IUserLogic userLogic,
IWebHostEnvironment webEnv,
IConfigHelper config)
@@ -75,6 +77,7 @@ namespace CarCareTracker.Controllers
_planRecordDataAccess = planRecordDataAccess;
_planRecordTemplateDataAccess = planRecordTemplateDataAccess;
_odometerRecordDataAccess = odometerRecordDataAccess;
_extraFieldDataAccess = extraFieldDataAccess;
_userLogic = userLogic;
_webEnv = webEnv;
_config = config;
@@ -151,6 +154,37 @@ namespace CarCareTracker.Controllers
_dataAccess.DeleteVehicle(vehicleId);
return Json(result);
}
[HttpPost]
public IActionResult DuplicateVehicleCollaborators(int sourceVehicleId, int destVehicleId)
{
try
{
//retrieve collaborators for both source and destination vehicle id.
if (_userLogic.UserCanEditVehicle(GetUserID(), sourceVehicleId) && _userLogic.UserCanEditVehicle(GetUserID(), destVehicleId))
{
var sourceCollaborators = _userLogic.GetCollaboratorsForVehicle(sourceVehicleId).Select(x => x.UserVehicle.UserId).ToList();
var destCollaborators = _userLogic.GetCollaboratorsForVehicle(destVehicleId).Select(x => x.UserVehicle.UserId).ToList();
sourceCollaborators.RemoveAll(x => destCollaborators.Contains(x));
if (sourceCollaborators.Any())
{
foreach (int collaboratorId in sourceCollaborators)
{
_userLogic.AddUserAccessToVehicle(collaboratorId, destVehicleId);
}
}
else
{
return Json(new OperationResponse { Success = false, Message = "Both vehicles already have identical collaborators" });
}
}
return Json(new OperationResponse { Success = true, Message = "Collaborators Copied" });
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
return Json(new OperationResponse { Success = false, Message = StaticHelper.GenericErrorMessage });
}
}
#region "Bulk Imports and Exports"
[HttpGet]
public IActionResult GetBulkImportModalPartialView(ImportMode mode)
@@ -256,7 +290,8 @@ namespace CarCareTracker.Controllers
PartNumber = x.PartNumber,
PartQuantity = x.Quantity.ToString(),
PartSupplier = x.PartSupplier,
Notes = x.Notes
Notes = x.Notes,
Tags = string.Join(" ", x.Tags)
});
using (var writer = new StreamWriter(fullExportFilePath))
{
@@ -422,6 +457,16 @@ namespace CarCareTracker.Controllers
if (convertedRecord.Gallons > 0)
{
_gasRecordDataAccess.SaveGasRecordToVehicle(convertedRecord);
if (_config.GetUserConfig(User).EnableAutoOdometerInsert)
{
_odometerRecordDataAccess.SaveOdometerRecordToVehicle(new OdometerRecord
{
Date = convertedRecord.Date,
VehicleId = convertedRecord.VehicleId,
Mileage = convertedRecord.Mileage,
Notes = $"Auto Insert From Gas Record via CSV Import. {convertedRecord.Notes}"
});
}
}
}
else if (mode == ImportMode.ServiceRecord)
@@ -437,6 +482,16 @@ namespace CarCareTracker.Controllers
Tags = string.IsNullOrWhiteSpace(importModel.Tags) ? [] : importModel.Tags.Split(" ").ToList()
};
_serviceRecordDataAccess.SaveServiceRecordToVehicle(convertedRecord);
if (_config.GetUserConfig(User).EnableAutoOdometerInsert)
{
_odometerRecordDataAccess.SaveOdometerRecordToVehicle(new OdometerRecord
{
Date = convertedRecord.Date,
VehicleId = convertedRecord.VehicleId,
Mileage = convertedRecord.Mileage,
Notes = $"Auto Insert From Service Record via CSV Import. {convertedRecord.Notes}"
});
}
}
else if (mode == ImportMode.OdometerRecord)
{
@@ -482,6 +537,16 @@ namespace CarCareTracker.Controllers
Tags = string.IsNullOrWhiteSpace(importModel.Tags) ? [] : importModel.Tags.Split(" ").ToList()
};
_collisionRecordDataAccess.SaveCollisionRecordToVehicle(convertedRecord);
if (_config.GetUserConfig(User).EnableAutoOdometerInsert)
{
_odometerRecordDataAccess.SaveOdometerRecordToVehicle(new OdometerRecord
{
Date = convertedRecord.Date,
VehicleId = convertedRecord.VehicleId,
Mileage = convertedRecord.Mileage,
Notes = $"Auto Insert From Repair Record via CSV Import. {convertedRecord.Notes}"
});
}
}
else if (mode == ImportMode.UpgradeRecord)
{
@@ -496,6 +561,16 @@ namespace CarCareTracker.Controllers
Tags = string.IsNullOrWhiteSpace(importModel.Tags) ? [] : importModel.Tags.Split(" ").ToList()
};
_upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(convertedRecord);
if (_config.GetUserConfig(User).EnableAutoOdometerInsert)
{
_odometerRecordDataAccess.SaveOdometerRecordToVehicle(new OdometerRecord
{
Date = convertedRecord.Date,
VehicleId = convertedRecord.VehicleId,
Mileage = convertedRecord.Mileage,
Notes = $"Auto Insert From Upgrade Record via CSV Import. {convertedRecord.Notes}"
});
}
}
else if (mode == ImportMode.SupplyRecord)
{
@@ -508,7 +583,8 @@ namespace CarCareTracker.Controllers
Quantity = decimal.Parse(importModel.PartQuantity, NumberStyles.Any),
Description = importModel.Description,
Cost = decimal.Parse(importModel.Cost, NumberStyles.Any),
Notes = importModel.Notes
Notes = importModel.Notes,
Tags = string.IsNullOrWhiteSpace(importModel.Tags) ? [] : importModel.Tags.Split(" ").ToList()
};
_supplyRecordDataAccess.SaveSupplyRecordToVehicle(convertedRecord);
}
@@ -587,7 +663,7 @@ namespace CarCareTracker.Controllers
var vehicleData = _dataAccess.GetVehicleById(vehicleId);
var vehicleIsElectric = vehicleData.IsElectric;
var vehicleUseHours = vehicleData.UseHours;
return PartialView("_GasModal", new GasRecordInputContainer() { UseKwh = vehicleIsElectric, UseHours = vehicleUseHours, GasRecord = new GasRecordInput() });
return PartialView("_GasModal", new GasRecordInputContainer() { UseKwh = vehicleIsElectric, UseHours = vehicleUseHours, GasRecord = new GasRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.GasRecord).ExtraFields } });
}
[HttpGet]
public IActionResult GetGasRecordForEditById(int gasRecordId)
@@ -605,7 +681,8 @@ namespace CarCareTracker.Controllers
IsFillToFull = result.IsFillToFull,
MissedFuelUp = result.MissedFuelUp,
Notes = result.Notes,
Tags = result.Tags
Tags = result.Tags,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.GasRecord).ExtraFields)
};
var vehicleData = _dataAccess.GetVehicleById(convertedResult.VehicleId);
var vehicleIsElectric = vehicleData.IsElectric;
@@ -666,17 +743,22 @@ namespace CarCareTracker.Controllers
}
//move files from temp.
serviceRecord.Files = serviceRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
var result = _serviceRecordDataAccess.SaveServiceRecordToVehicle(serviceRecord.ToServiceRecord());
if (result && serviceRecord.Supplies.Any())
if (serviceRecord.Supplies.Any())
{
RequisitionSupplyRecordsByUsage(serviceRecord.Supplies);
serviceRecord.RequisitionHistory = RequisitionSupplyRecordsByUsage(serviceRecord.Supplies, DateTime.Parse(serviceRecord.Date), serviceRecord.Description);
}
//push back any reminders
if (serviceRecord.ReminderRecordId != default)
{
PushbackRecurringReminderRecordWithChecks(serviceRecord.ReminderRecordId);
}
var result = _serviceRecordDataAccess.SaveServiceRecordToVehicle(serviceRecord.ToServiceRecord());
return Json(result);
}
[HttpGet]
public IActionResult GetAddServiceRecordPartialView()
{
return PartialView("_ServiceRecordModal", new ServiceRecordInput());
return PartialView("_ServiceRecordModal", new ServiceRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.ServiceRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetServiceRecordForEditById(int serviceRecordId)
@@ -693,7 +775,9 @@ namespace CarCareTracker.Controllers
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files,
Tags = result.Tags
Tags = result.Tags,
RequisitionHistory = result.RequisitionHistory,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.ServiceRecord).ExtraFields)
};
return PartialView("_ServiceRecordModal", convertedResult);
}
@@ -736,17 +820,22 @@ namespace CarCareTracker.Controllers
}
//move files from temp.
collisionRecord.Files = collisionRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
var result = _collisionRecordDataAccess.SaveCollisionRecordToVehicle(collisionRecord.ToCollisionRecord());
if (result && collisionRecord.Supplies.Any())
if (collisionRecord.Supplies.Any())
{
RequisitionSupplyRecordsByUsage(collisionRecord.Supplies);
collisionRecord.RequisitionHistory = RequisitionSupplyRecordsByUsage(collisionRecord.Supplies, DateTime.Parse(collisionRecord.Date), collisionRecord.Description);
}
//push back any reminders
if (collisionRecord.ReminderRecordId != default)
{
PushbackRecurringReminderRecordWithChecks(collisionRecord.ReminderRecordId);
}
var result = _collisionRecordDataAccess.SaveCollisionRecordToVehicle(collisionRecord.ToCollisionRecord());
return Json(result);
}
[HttpGet]
public IActionResult GetAddCollisionRecordPartialView()
{
return PartialView("_CollisionRecordModal", new CollisionRecordInput());
return PartialView("_CollisionRecordModal", new CollisionRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.RepairRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetCollisionRecordForEditById(int collisionRecordId)
@@ -763,7 +852,9 @@ namespace CarCareTracker.Controllers
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files,
Tags = result.Tags
Tags = result.Tags,
RequisitionHistory = result.RequisitionHistory,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.RepairRecord).ExtraFields)
};
return PartialView("_CollisionRecordModal", convertedResult);
}
@@ -797,17 +888,19 @@ namespace CarCareTracker.Controllers
var recurringFees = result.Where(x => x.IsRecurring);
if (recurringFees.Any())
{
foreach(TaxRecord recurringFee in recurringFees)
foreach (TaxRecord recurringFee in recurringFees)
{
var newDate = new DateTime();
if (recurringFee.RecurringInterval != ReminderMonthInterval.Other)
{
newDate = recurringFee.Date.AddMonths((int)recurringFee.RecurringInterval);
} else
}
else
{
newDate = recurringFee.Date.AddMonths(recurringFee.CustomMonthInterval);
}
if (DateTime.Now > newDate){
if (DateTime.Now > newDate)
{
recurringFee.IsRecurring = false;
var newRecurringFee = new TaxRecord()
{
@@ -820,7 +913,8 @@ namespace CarCareTracker.Controllers
RecurringInterval = recurringFee.RecurringInterval,
CustomMonthInterval = recurringFee.CustomMonthInterval,
Files = recurringFee.Files,
Tags = recurringFee.Tags
Tags = recurringFee.Tags,
ExtraFields = recurringFee.ExtraFields
};
_taxRecordDataAccess.SaveTaxRecordToVehicle(recurringFee);
_taxRecordDataAccess.SaveTaxRecordToVehicle(newRecurringFee);
@@ -833,13 +927,18 @@ namespace CarCareTracker.Controllers
{
//move files from temp.
taxRecord.Files = taxRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
//push back any reminders
if (taxRecord.ReminderRecordId != default)
{
PushbackRecurringReminderRecordWithChecks(taxRecord.ReminderRecordId);
}
var result = _taxRecordDataAccess.SaveTaxRecordToVehicle(taxRecord.ToTaxRecord());
return Json(result);
}
[HttpGet]
public IActionResult GetAddTaxRecordPartialView()
{
return PartialView("_TaxRecordModal", new TaxRecordInput());
return PartialView("_TaxRecordModal", new TaxRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.TaxRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetTaxRecordForEditById(int taxRecordId)
@@ -858,7 +957,8 @@ namespace CarCareTracker.Controllers
RecurringInterval = result.RecurringInterval,
CustomMonthInterval = result.CustomMonthInterval,
Files = result.Files,
Tags = result.Tags
Tags = result.Tags,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.TaxRecord).ExtraFields)
};
return PartialView("_TaxRecordModal", convertedResult);
}
@@ -880,6 +980,7 @@ namespace CarCareTracker.Controllers
var collisionRecords = _collisionRecordDataAccess.GetCollisionRecordsByVehicleId(vehicleId);
var taxRecords = _taxRecordDataAccess.GetTaxRecordsByVehicleId(vehicleId);
var upgradeRecords = _upgradeRecordDataAccess.GetUpgradeRecordsByVehicleId(vehicleId);
var odometerRecords = _odometerRecordDataAccess.GetOdometerRecordsByVehicleId(vehicleId);
var viewModel = new ReportViewModel();
//get totalCostMakeUp
viewModel.CostMakeUpForVehicle = new CostMakeUpForVehicle
@@ -897,10 +998,12 @@ namespace CarCareTracker.Controllers
allCosts.AddRange(_reportHelper.GetUpgradeRecordSum(upgradeRecords, 0));
allCosts.AddRange(_reportHelper.GetGasRecordSum(gasRecords, 0));
allCosts.AddRange(_reportHelper.GetTaxRecordSum(taxRecords, 0));
allCosts.AddRange(_reportHelper.GetOdometerRecordSum(odometerRecords, 0));
viewModel.CostForVehicleByMonth = allCosts.GroupBy(x => new { x.MonthName, x.MonthId }).OrderBy(x => x.Key.MonthId).Select(x => new CostForVehicleByMonth
{
MonthName = x.Key.MonthName,
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
DistanceTraveled = x.Any(y => y.MinMileage != default) ? x.Max(y => y.MaxMileage) - x.Where(y => y.MinMileage != default).Min(y => y.MinMileage) : 0
}).ToList();
//get reminders
var reminders = GetRemindersAndUrgency(vehicleId, DateTime.Now);
@@ -952,7 +1055,7 @@ namespace CarCareTracker.Controllers
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = x.Sum(y=>y.Cost)
Cost = x.Sum(y => y.Cost)
}).ToList();
viewModel.FuelMileageForVehicleByMonth = monthlyMileageData;
return PartialView("_Report", viewModel);
@@ -1023,8 +1126,9 @@ namespace CarCareTracker.Controllers
public IActionResult GetVehicleAttachments(int vehicleId, List<ImportMode> exportTabs)
{
List<GenericReportModel> attachmentData = new List<GenericReportModel>();
if (exportTabs.Contains(ImportMode.ServiceRecord)){
var records = _serviceRecordDataAccess.GetServiceRecordsByVehicleId(vehicleId).Where(x=>x.Files.Any());
if (exportTabs.Contains(ImportMode.ServiceRecord))
{
var records = _serviceRecordDataAccess.GetServiceRecordsByVehicleId(vehicleId).Where(x => x.Files.Any());
attachmentData.AddRange(records.Select(x => new GenericReportModel
{
Date = x.Date,
@@ -1091,7 +1195,8 @@ namespace CarCareTracker.Controllers
return Json(new OperationResponse { Success = false, Message = StaticHelper.GenericErrorMessage });
}
return Json(new OperationResponse { Success = true, Message = result });
} else
}
else
{
return Json(new OperationResponse { Success = false, Message = "No Attachments Found" });
}
@@ -1229,10 +1334,16 @@ namespace CarCareTracker.Controllers
var taxRecords = _taxRecordDataAccess.GetTaxRecordsByVehicleId(vehicleId);
allCosts.AddRange(_reportHelper.GetTaxRecordSum(taxRecords, year));
}
if (selectedMetrics.Contains(ImportMode.OdometerRecord))
{
var odometerRecords = _odometerRecordDataAccess.GetOdometerRecordsByVehicleId(vehicleId);
allCosts.AddRange(_reportHelper.GetOdometerRecordSum(odometerRecords, year));
}
var groupedRecord = allCosts.GroupBy(x => new { x.MonthName, x.MonthId }).OrderBy(x => x.Key.MonthId).Select(x => new CostForVehicleByMonth
{
MonthName = x.Key.MonthName,
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
DistanceTraveled = x.Any(y => y.MinMileage != default) ? x.Max(y => y.MaxMileage) - x.Where(y => y.MinMileage != default).Min(y => y.MinMileage) : 0
}).ToList();
return PartialView("_GasCostByMonthReport", groupedRecord);
}
@@ -1317,15 +1428,48 @@ namespace CarCareTracker.Controllers
result = result.OrderByDescending(x => x.Urgency).ToList();
return PartialView("_ReminderRecords", result);
}
[HttpGet]
public IActionResult GetRecurringReminderRecordsByVehicleId(int vehicleId)
{
var result = _reminderRecordDataAccess.GetReminderRecordsByVehicleId(vehicleId);
result.RemoveAll(x => !x.IsRecurring);
return PartialView("_RecurringReminderSelector", result);
}
[HttpPost]
public IActionResult PushbackRecurringReminderRecord(int reminderRecordId)
{
var existingReminder = _reminderRecordDataAccess.GetReminderRecordById(reminderRecordId);
existingReminder = _reminderHelper.GetUpdatedRecurringReminderRecord(existingReminder);
//save to db.
var result = _reminderRecordDataAccess.SaveReminderRecordToVehicle(existingReminder);
var result = PushbackRecurringReminderRecordWithChecks(reminderRecordId);
return Json(result);
}
private bool PushbackRecurringReminderRecordWithChecks(int reminderRecordId)
{
try
{
var existingReminder = _reminderRecordDataAccess.GetReminderRecordById(reminderRecordId);
if (existingReminder is not null && existingReminder.Id != default && existingReminder.IsRecurring)
{
existingReminder = _reminderHelper.GetUpdatedRecurringReminderRecord(existingReminder);
//save to db.
var reminderUpdateResult = _reminderRecordDataAccess.SaveReminderRecordToVehicle(existingReminder);
if (!reminderUpdateResult)
{
_logger.LogError("Unable to update reminder either because the reminder no longer exists or is no longer recurring");
return false;
}
return true;
}
else
{
_logger.LogError("Unable to update reminder because it no longer exists.");
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
return false;
}
}
[HttpPost]
public IActionResult SaveReminderRecordToVehicleId(ReminderRecordInput reminderRecord)
{
@@ -1405,17 +1549,22 @@ namespace CarCareTracker.Controllers
}
//move files from temp.
upgradeRecord.Files = upgradeRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
var result = _upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(upgradeRecord.ToUpgradeRecord());
if (result && upgradeRecord.Supplies.Any())
if (upgradeRecord.Supplies.Any())
{
RequisitionSupplyRecordsByUsage(upgradeRecord.Supplies);
upgradeRecord.RequisitionHistory = RequisitionSupplyRecordsByUsage(upgradeRecord.Supplies, DateTime.Parse(upgradeRecord.Date), upgradeRecord.Description);
}
//push back any reminders
if (upgradeRecord.ReminderRecordId != default)
{
PushbackRecurringReminderRecordWithChecks(upgradeRecord.ReminderRecordId);
}
var result = _upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(upgradeRecord.ToUpgradeRecord());
return Json(result);
}
[HttpGet]
public IActionResult GetAddUpgradeRecordPartialView()
{
return PartialView("_UpgradeRecordModal", new UpgradeRecordInput());
return PartialView("_UpgradeRecordModal", new UpgradeRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.UpgradeRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetUpgradeRecordForEditById(int upgradeRecordId)
@@ -1432,7 +1581,9 @@ namespace CarCareTracker.Controllers
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files,
Tags = result.Tags
Tags = result.Tags,
RequisitionHistory = result.RequisitionHistory,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.UpgradeRecord).ExtraFields)
};
return PartialView("_UpgradeRecordModal", convertedResult);
}
@@ -1457,7 +1608,7 @@ namespace CarCareTracker.Controllers
public IActionResult GetPinnedNotesByVehicleId(int vehicleId)
{
var result = _noteDataAccess.GetNotesByVehicleId(vehicleId);
result = result.Where(x=>x.Pinned).ToList();
result = result.Where(x => x.Pinned).ToList();
return Json(result);
}
[HttpPost]
@@ -1483,6 +1634,24 @@ namespace CarCareTracker.Controllers
var result = _noteDataAccess.DeleteNoteById(noteId);
return Json(result);
}
[HttpPost]
public IActionResult PinNotes(List<int> noteIds, bool isToggle = false, bool pinStatus = false)
{
var result = false;
foreach(int noteId in noteIds)
{
var existingNote = _noteDataAccess.GetNoteById(noteId);
if (isToggle)
{
existingNote.Pinned = !existingNote.Pinned;
} else
{
existingNote.Pinned = pinStatus;
}
result = _noteDataAccess.SaveNoteToVehicle(existingNote);
}
return Json(result);
}
#endregion
#region "Supply Records"
private List<string> CheckSupplyRecordsAvailability(List<SupplyUsage> supplyUsage)
@@ -1493,27 +1662,52 @@ namespace CarCareTracker.Controllers
{
//get supply record.
var supplyData = _supplyRecordDataAccess.GetSupplyRecordById(supply.SupplyId);
if (supply.Quantity > supplyData.Quantity)
if (supplyData == null)
{
result.Add("Missing Supplies, Please Delete This Template and Recreate It.");
}
else if (supply.Quantity > supplyData.Quantity)
{
result.Add($"Insufficient Quantity for {supplyData.Description}, need: {supply.Quantity}, available: {supplyData.Quantity}");
}
}
return result;
}
private void RequisitionSupplyRecordsByUsage(List<SupplyUsage> supplyUsage)
private List<SupplyUsageHistory> RequisitionSupplyRecordsByUsage(List<SupplyUsage> supplyUsage, DateTime dateRequisitioned, string usageDescription)
{
foreach(SupplyUsage supply in supplyUsage)
List<SupplyUsageHistory> results = new List<SupplyUsageHistory>();
foreach (SupplyUsage supply in supplyUsage)
{
//get supply record.
var result = _supplyRecordDataAccess.GetSupplyRecordById(supply.SupplyId);
var unitCost = (result.Quantity != 0 ) ? result.Cost / result.Quantity : 0;
var unitCost = (result.Quantity != 0) ? result.Cost / result.Quantity : 0;
//deduct quantity used.
result.Quantity -= supply.Quantity;
//deduct cost.
result.Cost -= (supply.Quantity * unitCost);
//check decimal places to ensure that it always has a max of 3 decimal places.
var roundedDecimal = decimal.Round(result.Cost, 3);
if (roundedDecimal != result.Cost)
{
//Too many decimals
result.Cost = roundedDecimal;
}
//create new requisitionrrecord
var requisitionRecord = new SupplyUsageHistory
{
Date = dateRequisitioned,
Description = usageDescription,
Quantity = supply.Quantity,
Cost = (supply.Quantity * unitCost)
};
result.RequisitionHistory.Add(requisitionRecord);
//save
_supplyRecordDataAccess.SaveSupplyRecordToVehicle(result);
requisitionRecord.Description = result.Description; //change the name of the description for plan/service/repair/upgrade records
requisitionRecord.PartNumber = result.PartNumber; //populate part number if not displayed in supplies modal.
results.Add(requisitionRecord);
}
return results;
}
[TypeFilter(typeof(CollaboratorFilter))]
[HttpGet]
@@ -1563,7 +1757,7 @@ namespace CarCareTracker.Controllers
[HttpGet]
public IActionResult GetAddSupplyRecordPartialView()
{
return PartialView("_SupplyRecordModal", new SupplyRecordInput());
return PartialView("_SupplyRecordModal", new SupplyRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.SupplyRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetSupplyRecordForEditById(int supplyRecordId)
@@ -1581,7 +1775,10 @@ namespace CarCareTracker.Controllers
PartSupplier = result.PartSupplier,
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files
Files = result.Files,
Tags = result.Tags,
RequisitionHistory = result.RequisitionHistory,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.SupplyRecord).ExtraFields)
};
return PartialView("_SupplyRecordModal", convertedResult);
}
@@ -1611,21 +1808,21 @@ namespace CarCareTracker.Controllers
planRecord.DateModified = DateTime.Now.ToString("G");
//move files from temp.
planRecord.Files = planRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
var result = _planRecordDataAccess.SavePlanRecordToVehicle(planRecord.ToPlanRecord());
if (result && planRecord.Supplies.Any())
if (planRecord.Supplies.Any())
{
RequisitionSupplyRecordsByUsage(planRecord.Supplies);
planRecord.RequisitionHistory = RequisitionSupplyRecordsByUsage(planRecord.Supplies, DateTime.Parse(planRecord.DateCreated), planRecord.Description);
}
var result = _planRecordDataAccess.SavePlanRecordToVehicle(planRecord.ToPlanRecord());
return Json(result);
}
[HttpPost]
public IActionResult SavePlanRecordTemplateToVehicleId(PlanRecordInput planRecord)
{
//check if template name already taken.
var existingRecord = _planRecordTemplateDataAccess.GetPlanRecordTemplatesByVehicleId(planRecord.VehicleId).Where(x=>x.Description == planRecord.Description).Any();
var existingRecord = _planRecordTemplateDataAccess.GetPlanRecordTemplatesByVehicleId(planRecord.VehicleId).Where(x => x.Description == planRecord.Description).Any();
if (existingRecord)
{
return Json(new OperationResponse { Success = false, Message = "A template with that description already exists for this vehicle"});
return Json(new OperationResponse { Success = false, Message = "A template with that description already exists for this vehicle" });
}
planRecord.Files = planRecord.Files.Select(x => { return new UploadedFiles { Name = x.Name, Location = _fileHelper.MoveFileFromTemp(x.Location, "documents/") }; }).ToList();
var result = _planRecordTemplateDataAccess.SavePlanRecordTemplateToVehicle(planRecord);
@@ -1661,21 +1858,40 @@ namespace CarCareTracker.Controllers
return Json(new OperationResponse { Success = false, Message = string.Join("<br>", supplyAvailability) });
}
}
if (existingRecord.ReminderRecordId != default)
{
//check if reminder still exists and is still recurring.
var existingReminder = _reminderRecordDataAccess.GetReminderRecordById(existingRecord.ReminderRecordId);
if (existingReminder is null || existingReminder.Id == default || !existingReminder.IsRecurring)
{
return Json(new OperationResponse { Success = false, Message = "Missing or Non-recurring Reminder, Please Delete This Template and Recreate It." });
}
}
//populate createdDate
existingRecord.DateCreated = DateTime.Now.ToString("G");
existingRecord.DateModified = DateTime.Now.ToString("G");
existingRecord.Id = default;
var result = _planRecordDataAccess.SavePlanRecordToVehicle(existingRecord.ToPlanRecord());
if (result && existingRecord.Supplies.Any())
if (existingRecord.Supplies.Any())
{
RequisitionSupplyRecordsByUsage(existingRecord.Supplies);
existingRecord.RequisitionHistory = RequisitionSupplyRecordsByUsage(existingRecord.Supplies, DateTime.Parse(existingRecord.DateCreated), existingRecord.Description);
}
var result = _planRecordDataAccess.SavePlanRecordToVehicle(existingRecord.ToPlanRecord());
return Json(new OperationResponse { Success = result, Message = result ? "Plan Record Added" : StaticHelper.GenericErrorMessage });
}
[HttpGet]
public IActionResult GetAddPlanRecordPartialView()
{
return PartialView("_PlanRecordModal", new PlanRecordInput());
return PartialView("_PlanRecordModal", new PlanRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.PlanRecord).ExtraFields });
}
[HttpPost]
public IActionResult GetAddPlanRecordPartialView(PlanRecordInput? planModel)
{
if (planModel is not null)
{
planModel.ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.PlanRecord).ExtraFields;
return PartialView("_PlanRecordModal", planModel);
}
return PartialView("_PlanRecordModal", new PlanRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.PlanRecord).ExtraFields });
}
[HttpPost]
public IActionResult UpdatePlanRecordProgress(int planRecordId, PlanProgress planProgress, int odometer = 0)
@@ -1693,7 +1909,8 @@ namespace CarCareTracker.Controllers
Date = DateTime.Now,
VehicleId = existingRecord.VehicleId,
Mileage = odometer,
Notes = $"Auto Insert From Plan Record: {existingRecord.Description}"
Notes = $"Auto Insert From Plan Record: {existingRecord.Description}",
ExtraFields = existingRecord.ExtraFields
});
}
//convert plan record to service/upgrade/repair record.
@@ -1707,7 +1924,9 @@ namespace CarCareTracker.Controllers
Description = existingRecord.Description,
Cost = existingRecord.Cost,
Notes = existingRecord.Notes,
Files = existingRecord.Files
Files = existingRecord.Files,
RequisitionHistory = existingRecord.RequisitionHistory,
ExtraFields = existingRecord.ExtraFields
};
_serviceRecordDataAccess.SaveServiceRecordToVehicle(newRecord);
}
@@ -1721,7 +1940,9 @@ namespace CarCareTracker.Controllers
Description = existingRecord.Description,
Cost = existingRecord.Cost,
Notes = existingRecord.Notes,
Files = existingRecord.Files
Files = existingRecord.Files,
RequisitionHistory = existingRecord.RequisitionHistory,
ExtraFields = existingRecord.ExtraFields
};
_collisionRecordDataAccess.SaveCollisionRecordToVehicle(newRecord);
}
@@ -1735,10 +1956,17 @@ namespace CarCareTracker.Controllers
Description = existingRecord.Description,
Cost = existingRecord.Cost,
Notes = existingRecord.Notes,
Files = existingRecord.Files
Files = existingRecord.Files,
RequisitionHistory = existingRecord.RequisitionHistory,
ExtraFields = existingRecord.ExtraFields
};
_upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(newRecord);
}
//push back any reminders
if (existingRecord.ReminderRecordId != default)
{
PushbackRecurringReminderRecordWithChecks(existingRecord.ReminderRecordId);
}
}
return Json(result);
}
@@ -1759,7 +1987,10 @@ namespace CarCareTracker.Controllers
Cost = result.Cost,
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files
Files = result.Files,
RequisitionHistory = result.RequisitionHistory,
ReminderRecordId = result.ReminderRecordId,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.PlanRecord).ExtraFields)
};
return PartialView("_PlanRecordModal", convertedResult);
}
@@ -1798,7 +2029,7 @@ namespace CarCareTracker.Controllers
[HttpGet]
public IActionResult GetAddOdometerRecordPartialView()
{
return PartialView("_OdometerRecordModal", new OdometerRecordInput());
return PartialView("_OdometerRecordModal", new OdometerRecordInput() { ExtraFields = _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.OdometerRecord).ExtraFields });
}
[HttpGet]
public IActionResult GetOdometerRecordForEditById(int odometerRecordId)
@@ -1813,7 +2044,8 @@ namespace CarCareTracker.Controllers
Notes = result.Notes,
VehicleId = result.VehicleId,
Files = result.Files,
Tags = result.Tags
Tags = result.Tags,
ExtraFields = StaticHelper.AddExtraFields(result.ExtraFields, _extraFieldDataAccess.GetExtraFieldsById((int)ImportMode.OdometerRecord).ExtraFields)
};
return PartialView("_OdometerRecordModal", convertedResult);
}
@@ -1873,6 +2105,292 @@ namespace CarCareTracker.Controllers
}
return Json(result);
}
public IActionResult MoveRecords(List<int> recordIds, ImportMode source, ImportMode destination)
{
var genericRecord = new GenericRecord();
bool result = false;
foreach (int recordId in recordIds)
{
//get
switch (source)
{
case ImportMode.ServiceRecord:
genericRecord = _serviceRecordDataAccess.GetServiceRecordById(recordId);
break;
case ImportMode.RepairRecord:
genericRecord = _collisionRecordDataAccess.GetCollisionRecordById(recordId);
break;
case ImportMode.UpgradeRecord:
genericRecord = _upgradeRecordDataAccess.GetUpgradeRecordById(recordId);
break;
}
//save
switch (destination)
{
case ImportMode.ServiceRecord:
result = _serviceRecordDataAccess.SaveServiceRecordToVehicle(StaticHelper.GenericToServiceRecord(genericRecord));
break;
case ImportMode.RepairRecord:
result = _collisionRecordDataAccess.SaveCollisionRecordToVehicle(StaticHelper.GenericToRepairRecord(genericRecord));
break;
case ImportMode.UpgradeRecord:
result = _upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(StaticHelper.GenericToUpgradeRecord(genericRecord));
break;
}
//delete
if (result)
{
switch (source)
{
case ImportMode.ServiceRecord:
_serviceRecordDataAccess.DeleteServiceRecordById(recordId);
break;
case ImportMode.RepairRecord:
_collisionRecordDataAccess.DeleteCollisionRecordById(recordId);
break;
case ImportMode.UpgradeRecord:
_upgradeRecordDataAccess.DeleteUpgradeRecordById(recordId);
break;
}
}
}
return Json(result);
}
public IActionResult DeleteRecords(List<int> recordIds, ImportMode importMode)
{
bool result = false;
foreach (int recordId in recordIds)
{
switch (importMode)
{
case ImportMode.ServiceRecord:
result = _serviceRecordDataAccess.DeleteServiceRecordById(recordId);
break;
case ImportMode.RepairRecord:
result = _collisionRecordDataAccess.DeleteCollisionRecordById(recordId);
break;
case ImportMode.UpgradeRecord:
result = _upgradeRecordDataAccess.DeleteUpgradeRecordById(recordId);
break;
case ImportMode.GasRecord:
result = _gasRecordDataAccess.DeleteGasRecordById(recordId);
break;
case ImportMode.TaxRecord:
result = _taxRecordDataAccess.DeleteTaxRecordById(recordId);
break;
case ImportMode.SupplyRecord:
result = _supplyRecordDataAccess.DeleteSupplyRecordById(recordId);
break;
case ImportMode.NoteRecord:
result = _noteDataAccess.DeleteNoteById(recordId);
break;
case ImportMode.OdometerRecord:
result = _odometerRecordDataAccess.DeleteOdometerRecordById(recordId);
break;
case ImportMode.ReminderRecord:
result = _reminderRecordDataAccess.DeleteReminderRecordById(recordId);
break;
}
}
return Json(result);
}
public IActionResult DuplicateRecords(List<int> recordIds, ImportMode importMode)
{
bool result = false;
foreach (int recordId in recordIds)
{
switch (importMode)
{
case ImportMode.ServiceRecord:
{
var existingRecord = _serviceRecordDataAccess.GetServiceRecordById(recordId);
existingRecord.Id = default;
result = _serviceRecordDataAccess.SaveServiceRecordToVehicle(existingRecord);
}
break;
case ImportMode.RepairRecord:
{
var existingRecord = _collisionRecordDataAccess.GetCollisionRecordById(recordId);
existingRecord.Id = default;
result = _collisionRecordDataAccess.SaveCollisionRecordToVehicle(existingRecord);
}
break;
case ImportMode.UpgradeRecord:
{
var existingRecord = _upgradeRecordDataAccess.GetUpgradeRecordById(recordId);
existingRecord.Id = default;
result = _upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(existingRecord);
}
break;
case ImportMode.GasRecord:
{
var existingRecord = _gasRecordDataAccess.GetGasRecordById(recordId);
existingRecord.Id = default;
result = _gasRecordDataAccess.SaveGasRecordToVehicle(existingRecord);
}
break;
case ImportMode.TaxRecord:
{
var existingRecord = _taxRecordDataAccess.GetTaxRecordById(recordId);
existingRecord.Id = default;
result = _taxRecordDataAccess.SaveTaxRecordToVehicle(existingRecord);
}
break;
case ImportMode.SupplyRecord:
{
var existingRecord = _supplyRecordDataAccess.GetSupplyRecordById(recordId);
existingRecord.Id = default;
result = _supplyRecordDataAccess.SaveSupplyRecordToVehicle(existingRecord);
}
break;
case ImportMode.NoteRecord:
{
var existingRecord = _noteDataAccess.GetNoteById(recordId);
existingRecord.Id = default;
result = _noteDataAccess.SaveNoteToVehicle(existingRecord);
}
break;
case ImportMode.OdometerRecord:
{
var existingRecord = _odometerRecordDataAccess.GetOdometerRecordById(recordId);
existingRecord.Id = default;
result = _odometerRecordDataAccess.SaveOdometerRecordToVehicle(existingRecord);
}
break;
case ImportMode.ReminderRecord:
{
var existingRecord = _reminderRecordDataAccess.GetReminderRecordById(recordId);
existingRecord.Id = default;
result = _reminderRecordDataAccess.SaveReminderRecordToVehicle(existingRecord);
}
break;
}
}
return Json(result);
}
[HttpPost]
public IActionResult GetGenericRecordModal(List<int> recordIds, ImportMode dataType)
{
return PartialView("_GenericRecordModal", new GenericRecordEditModel() { DataType = dataType, RecordIds = recordIds });
}
[HttpPost]
public IActionResult EditMultipleRecords(GenericRecordEditModel genericRecordEditModel)
{
var dateIsEdited = genericRecordEditModel.EditRecord.Date != default;
var descriptionIsEdited = !string.IsNullOrWhiteSpace(genericRecordEditModel.EditRecord.Description);
var mileageIsEdited = genericRecordEditModel.EditRecord.Mileage != default;
var costIsEdited = genericRecordEditModel.EditRecord.Cost != default;
var noteIsEdited = !string.IsNullOrWhiteSpace(genericRecordEditModel.EditRecord.Notes);
var tagsIsEdited = genericRecordEditModel.EditRecord.Tags.Any();
//handle clear overrides
if (tagsIsEdited && genericRecordEditModel.EditRecord.Tags.Contains("---"))
{
genericRecordEditModel.EditRecord.Tags = new List<string>();
}
if (noteIsEdited && genericRecordEditModel.EditRecord.Notes == "---")
{
genericRecordEditModel.EditRecord.Notes = "";
}
bool result = false;
foreach (int recordId in genericRecordEditModel.RecordIds)
{
switch (genericRecordEditModel.DataType)
{
case ImportMode.ServiceRecord:
{
var existingRecord = _serviceRecordDataAccess.GetServiceRecordById(recordId);
if (dateIsEdited)
{
existingRecord.Date = genericRecordEditModel.EditRecord.Date;
}
if (descriptionIsEdited)
{
existingRecord.Description = genericRecordEditModel.EditRecord.Description;
}
if (mileageIsEdited)
{
existingRecord.Mileage = genericRecordEditModel.EditRecord.Mileage;
}
if (costIsEdited)
{
existingRecord.Cost = genericRecordEditModel.EditRecord.Cost;
}
if (noteIsEdited)
{
existingRecord.Notes = genericRecordEditModel.EditRecord.Notes;
}
if (tagsIsEdited)
{
existingRecord.Tags = genericRecordEditModel.EditRecord.Tags;
}
result = _serviceRecordDataAccess.SaveServiceRecordToVehicle(existingRecord);
}
break;
case ImportMode.RepairRecord:
{
var existingRecord = _collisionRecordDataAccess.GetCollisionRecordById(recordId);
if (dateIsEdited)
{
existingRecord.Date = genericRecordEditModel.EditRecord.Date;
}
if (descriptionIsEdited)
{
existingRecord.Description = genericRecordEditModel.EditRecord.Description;
}
if (mileageIsEdited)
{
existingRecord.Mileage = genericRecordEditModel.EditRecord.Mileage;
}
if (costIsEdited)
{
existingRecord.Cost = genericRecordEditModel.EditRecord.Cost;
}
if (noteIsEdited)
{
existingRecord.Notes = genericRecordEditModel.EditRecord.Notes;
}
if (tagsIsEdited)
{
existingRecord.Tags = genericRecordEditModel.EditRecord.Tags;
}
result = _collisionRecordDataAccess.SaveCollisionRecordToVehicle(existingRecord);
}
break;
case ImportMode.UpgradeRecord:
{
var existingRecord = _upgradeRecordDataAccess.GetUpgradeRecordById(recordId);
if (dateIsEdited)
{
existingRecord.Date = genericRecordEditModel.EditRecord.Date;
}
if (descriptionIsEdited)
{
existingRecord.Description = genericRecordEditModel.EditRecord.Description;
}
if (mileageIsEdited)
{
existingRecord.Mileage = genericRecordEditModel.EditRecord.Mileage;
}
if (costIsEdited)
{
existingRecord.Cost = genericRecordEditModel.EditRecord.Cost;
}
if (noteIsEdited)
{
existingRecord.Notes = genericRecordEditModel.EditRecord.Notes;
}
if (tagsIsEdited)
{
existingRecord.Tags = genericRecordEditModel.EditRecord.Tags;
}
result = _upgradeRecordDataAccess.SaveUpgradeRecordToVehicle(existingRecord);
}
break;
}
}
return Json(result);
}
#endregion
}
}

View File

@@ -0,0 +1,30 @@
using CarCareTracker.External.Interfaces;
using CarCareTracker.Helper;
using CarCareTracker.Models;
using LiteDB;
namespace CarCareTracker.External.Implementations
{
public class ExtraFieldDataAccess: IExtraFieldDataAccess
{
private static string dbName = StaticHelper.DbName;
private static string tableName = "extrafields";
public RecordExtraField GetExtraFieldsById(int importMode)
{
using (var db = new LiteDatabase(dbName))
{
var table = db.GetCollection<RecordExtraField>(tableName);
return table.FindById(importMode) ?? new RecordExtraField();
};
}
public bool SaveExtraFields(RecordExtraField record)
{
using (var db = new LiteDatabase(dbName))
{
var table = db.GetCollection<RecordExtraField>(tableName);
table.Upsert(record);
return true;
};
}
}
}

View File

@@ -0,0 +1,76 @@
using CarCareTracker.External.Interfaces;
using CarCareTracker.Models;
using Npgsql;
using System.Text.Json;
namespace CarCareTracker.External.Implementations
{
public class PGExtraFieldDataAccess : IExtraFieldDataAccess
{
private NpgsqlDataSource pgDataSource;
private readonly ILogger<PGExtraFieldDataAccess> _logger;
private static string tableName = "extrafields";
public PGExtraFieldDataAccess(IConfiguration config, ILogger<PGExtraFieldDataAccess> logger)
{
pgDataSource = NpgsqlDataSource.Create(config["POSTGRES_CONNECTION"]);
_logger = logger;
try
{
//create table if not exist.
string initCMD = $"CREATE TABLE IF NOT EXISTS app.{tableName} (id INT primary key, data jsonb not null)";
using (var ctext = pgDataSource.CreateCommand(initCMD))
{
ctext.ExecuteNonQuery();
}
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
}
}
public RecordExtraField GetExtraFieldsById(int importMode)
{
try
{
string cmd = $"SELECT data FROM app.{tableName} WHERE id = @id";
var results = new RecordExtraField();
using (var ctext = pgDataSource.CreateCommand(cmd))
{
ctext.Parameters.AddWithValue("id", importMode);
using (NpgsqlDataReader reader = ctext.ExecuteReader())
while (reader.Read())
{
RecordExtraField result = JsonSerializer.Deserialize<RecordExtraField>(reader["data"] as string);
results = result;
}
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
return new RecordExtraField();
}
}
public bool SaveExtraFields(RecordExtraField record)
{
try
{
var existingRecord = GetExtraFieldsById(record.Id);
string cmd = $"INSERT INTO app.{tableName} (id, data) VALUES(@id, CAST(@data AS jsonb)) ON CONFLICT(id) DO UPDATE SET data = CAST(@data AS jsonb)";
using (var ctext = pgDataSource.CreateCommand(cmd))
{
ctext.Parameters.AddWithValue("id", record.Id);
ctext.Parameters.AddWithValue("data", JsonSerializer.Serialize(record));
return ctext.ExecuteNonQuery() > 0;
}
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
return false;
}
}
}
}

View File

@@ -0,0 +1,10 @@
using CarCareTracker.Models;
namespace CarCareTracker.External.Interfaces
{
public interface IExtraFieldDataAccess
{
public RecordExtraField GetExtraFieldsById(int importMode);
public bool SaveExtraFields(RecordExtraField record);
}
}

View File

@@ -7,6 +7,7 @@ namespace CarCareTracker.Helper
{
public interface IConfigHelper
{
OpenIDConfig GetOpenIDConfig();
UserConfig GetUserConfig(ClaimsPrincipal user);
bool SaveUserConfig(ClaimsPrincipal user, UserConfig configData);
string GetLogoUrl();
@@ -28,6 +29,11 @@ namespace CarCareTracker.Helper
_userConfig = userConfig;
_cache = memoryCache;
}
public OpenIDConfig GetOpenIDConfig()
{
OpenIDConfig openIdConfig = _config.GetSection("OpenIDConfig").Get<OpenIDConfig>() ?? new OpenIDConfig();
return openIdConfig;
}
public string GetLogoUrl()
{
var logoUrl = _config["LUBELOGGER_LOGO_URL"];

View File

@@ -5,6 +5,7 @@ namespace CarCareTracker.Helper
{
public interface IReportHelper
{
IEnumerable<CostForVehicleByMonth> GetOdometerRecordSum(List<OdometerRecord> odometerRecords, int year = 0);
IEnumerable<CostForVehicleByMonth> GetServiceRecordSum(List<ServiceRecord> serviceRecords, int year = 0);
IEnumerable<CostForVehicleByMonth> GetRepairRecordSum(List<CollisionRecord> repairRecords, int year = 0);
IEnumerable<CostForVehicleByMonth> GetUpgradeRecordSum(List<UpgradeRecord> upgradeRecords, int year = 0);
@@ -13,6 +14,21 @@ namespace CarCareTracker.Helper
}
public class ReportHelper: IReportHelper
{
public IEnumerable<CostForVehicleByMonth> GetOdometerRecordSum(List<OdometerRecord> odometerRecords, int year = 0)
{
if (year != default)
{
odometerRecords.RemoveAll(x => x.Date.Year != year);
}
return odometerRecords.GroupBy(x => x.Date.Month).OrderBy(x => x.Key).Select(x => new CostForVehicleByMonth
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = 0,
MaxMileage = x.Max(y => y.Mileage),
MinMileage = x.Min(y => y.Mileage)
});
}
public IEnumerable<CostForVehicleByMonth> GetServiceRecordSum(List<ServiceRecord> serviceRecords, int year = 0)
{
if (year != default)
@@ -23,7 +39,9 @@ namespace CarCareTracker.Helper
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
MaxMileage = x.Max(y=>y.Mileage),
MinMileage = x.Min(y=>y.Mileage)
});
}
public IEnumerable<CostForVehicleByMonth> GetRepairRecordSum(List<CollisionRecord> repairRecords, int year = 0)
@@ -36,7 +54,9 @@ namespace CarCareTracker.Helper
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
MaxMileage = x.Max(y => y.Mileage),
MinMileage = x.Min(y => y.Mileage)
});
}
public IEnumerable<CostForVehicleByMonth> GetUpgradeRecordSum(List<UpgradeRecord> upgradeRecords, int year = 0)
@@ -49,7 +69,9 @@ namespace CarCareTracker.Helper
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
MaxMileage = x.Max(y => y.Mileage),
MinMileage = x.Min(y => y.Mileage)
});
}
public IEnumerable<CostForVehicleByMonth> GetGasRecordSum(List<GasRecord> gasRecords, int year = 0)
@@ -62,7 +84,9 @@ namespace CarCareTracker.Helper
{
MonthId = x.Key,
MonthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(x.Key),
Cost = x.Sum(y => y.Cost)
Cost = x.Sum(y => y.Cost),
MaxMileage = x.Max(y => y.Mileage),
MinMileage = x.Min(y => y.Mileage)
});
}
public IEnumerable<CostForVehicleByMonth> GetTaxRecordSum(List<TaxRecord> taxRecords, int year = 0)

View File

@@ -128,7 +128,9 @@ namespace CarCareTracker.Helper
Mileage = input.Mileage,
Files = input.Files,
Notes = input.Notes,
Tags = input.Tags
Tags = input.Tags,
ExtraFields = input.ExtraFields,
RequisitionHistory = input.RequisitionHistory
};
}
public static CollisionRecord GenericToRepairRecord(GenericRecord input)
@@ -142,7 +144,9 @@ namespace CarCareTracker.Helper
Mileage = input.Mileage,
Files = input.Files,
Notes = input.Notes,
Tags = input.Tags
Tags = input.Tags,
ExtraFields = input.ExtraFields,
RequisitionHistory = input.RequisitionHistory
};
}
public static UpgradeRecord GenericToUpgradeRecord(GenericRecord input)
@@ -156,10 +160,36 @@ namespace CarCareTracker.Helper
Mileage = input.Mileage,
Files = input.Files,
Notes = input.Notes,
Tags = input.Tags
Tags = input.Tags,
ExtraFields = input.ExtraFields,
RequisitionHistory = input.RequisitionHistory
};
}
public static List<ExtraField> AddExtraFields(List<ExtraField> recordExtraFields, List<ExtraField> templateExtraFields)
{
if (!templateExtraFields.Any()) {
return new List<ExtraField>();
}
if (!recordExtraFields.Any())
{
return templateExtraFields;
}
var fieldNames = templateExtraFields.Select(x => x.Name);
//remove fields that are no longer present in template.
recordExtraFields.RemoveAll(x => !fieldNames.Contains(x.Name));
if (!recordExtraFields.Any())
{
return templateExtraFields;
}
//append the fields.
foreach (ExtraField extraField in recordExtraFields)
{
extraField.IsRequired = templateExtraFields.Where(x => x.Name == extraField.Name).First().IsRequired;
}
return recordExtraFields;
}
public static string GetFuelEconomyUnit(bool useKwh, bool useHours, bool useMPG, bool useUKMPG)
{
string fuelEconomyUnit;
@@ -182,5 +212,9 @@ namespace CarCareTracker.Helper
}
return fuelEconomyUnit;
}
public static long GetEpochFromDateTime(DateTime date)
{
return new DateTimeOffset(date).ToUnixTimeMilliseconds();
}
}
}

View File

@@ -1,5 +1,4 @@
using CarCareTracker.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;
namespace CarCareTracker.Helper

View File

@@ -2,8 +2,6 @@
using CarCareTracker.Helper;
using CarCareTracker.Models;
using Microsoft.Extensions.Caching.Memory;
using System.Net;
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -16,11 +14,13 @@ namespace CarCareTracker.Logic
OperationResponse GenerateUserToken(string emailAddress, bool autoNotify);
bool DeleteUserToken(int tokenId);
bool DeleteUser(int userId);
OperationResponse RegisterOpenIdUser(LoginModel credentials);
OperationResponse RegisterNewUser(LoginModel credentials);
OperationResponse RequestResetPassword(LoginModel credentials);
OperationResponse ResetPasswordByUser(LoginModel credentials);
OperationResponse ResetUserPassword(LoginModel credentials);
UserData ValidateUserCredentials(LoginModel credentials);
UserData ValidateOpenIDUser(LoginModel credentials);
bool CheckIfUserIsValid(int userId);
bool CreateRootUserCredentials(LoginModel credentials);
bool DeleteRootUserCredentials();
@@ -59,6 +59,45 @@ namespace CarCareTracker.Logic
return result.Id != 0;
}
}
public OperationResponse RegisterOpenIdUser(LoginModel credentials)
{
//validate their token.
var existingToken = _tokenData.GetTokenRecordByBody(credentials.Token);
if (existingToken.Id == default || existingToken.EmailAddress != credentials.EmailAddress)
{
return new OperationResponse { Success = false, Message = "Invalid Token" };
}
if (string.IsNullOrWhiteSpace(credentials.EmailAddress) || string.IsNullOrWhiteSpace(credentials.UserName))
{
return new OperationResponse { Success = false, Message = "Username cannot be blank" };
}
var existingUser = _userData.GetUserRecordByUserName(credentials.UserName);
if (existingUser.Id != default)
{
return new OperationResponse { Success = false, Message = "Username already taken" };
}
var existingUserWithEmail = _userData.GetUserRecordByEmailAddress(credentials.EmailAddress);
if (existingUserWithEmail.Id != default)
{
return new OperationResponse { Success = false, Message = "A user with that email already exists" };
}
_tokenData.DeleteToken(existingToken.Id);
var newUser = new UserData()
{
UserName = credentials.UserName,
Password = GetHash(NewToken()), //generate a password for OpenID User
EmailAddress = credentials.EmailAddress
};
var result = _userData.SaveUserRecord(newUser);
if (result)
{
return new OperationResponse { Success = true, Message = "You will be logged in briefly." };
}
else
{
return new OperationResponse { Success = false, Message = "Something went wrong, please try again later." };
}
}
//handles user registration
public OperationResponse RegisterNewUser(LoginModel credentials)
{
@@ -193,6 +232,19 @@ namespace CarCareTracker.Logic
}
}
}
public UserData ValidateOpenIDUser(LoginModel credentials)
{
var result = _userData.GetUserRecordByEmailAddress(credentials.EmailAddress);
if (result.Id != default)
{
result.Password = string.Empty;
return result;
}
else
{
return new UserData();
}
}
#region "Admin Functions"
public bool MakeUserAdmin(int userId, bool isAdmin)
{
@@ -280,19 +332,32 @@ namespace CarCareTracker.Logic
#region "Root User"
public bool CreateRootUserCredentials(LoginModel credentials)
{
var configFileContents = File.ReadAllText(StaticHelper.UserConfigPath);
var existingUserConfig = JsonSerializer.Deserialize<UserConfig>(configFileContents);
if (existingUserConfig is not null)
//check if file exists
if (File.Exists(StaticHelper.UserConfigPath))
{
//create hashes of the login credentials.
var hashedUserName = GetHash(credentials.UserName);
var hashedPassword = GetHash(credentials.Password);
//copy over settings that are off limits on the settings page.
existingUserConfig.EnableAuth = true;
existingUserConfig.UserNameHash = hashedUserName;
existingUserConfig.UserPasswordHash = hashedPassword;
var configFileContents = File.ReadAllText(StaticHelper.UserConfigPath);
var existingUserConfig = JsonSerializer.Deserialize<UserConfig>(configFileContents);
if (existingUserConfig is not null)
{
//create hashes of the login credentials.
var hashedUserName = GetHash(credentials.UserName);
var hashedPassword = GetHash(credentials.Password);
//copy over settings that are off limits on the settings page.
existingUserConfig.EnableAuth = true;
existingUserConfig.UserNameHash = hashedUserName;
existingUserConfig.UserPasswordHash = hashedPassword;
}
File.WriteAllText(StaticHelper.UserConfigPath, JsonSerializer.Serialize(existingUserConfig));
} else
{
var newUserConfig = new UserConfig()
{
EnableAuth = true,
UserNameHash = GetHash(credentials.UserName),
UserPasswordHash = GetHash(credentials.Password)
};
File.WriteAllText(StaticHelper.UserConfigPath, JsonSerializer.Serialize(newUserConfig));
}
File.WriteAllText(StaticHelper.UserConfigPath, JsonSerializer.Serialize(existingUserConfig));
_cache.Remove("userConfig_-1");
return true;
}

View File

@@ -1,7 +1,6 @@
using CarCareTracker.External.Interfaces;
using CarCareTracker.Helper;
using CarCareTracker.Models;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace CarCareTracker.Logic
{

View File

@@ -1,9 +1,7 @@
using CarCareTracker.Logic;
using CarCareTracker.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public string Date { get; set; } = DateTime.Now.ToShortDateString();
public int Mileage { get; set; }
public string Description { get; set; }
@@ -12,6 +13,21 @@
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<SupplyUsage> Supplies { get; set; } = new List<SupplyUsage>();
public List<string> Tags { get; set; } = new List<string>();
public CollisionRecord ToCollisionRecord() { return new CollisionRecord { Id = Id, VehicleId = VehicleId, Date = DateTime.Parse(Date), Cost = Cost, Mileage = Mileage, Description = Description, Notes = Notes, Files = Files, Tags = Tags }; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
public CollisionRecord ToCollisionRecord() { return new CollisionRecord {
Id = Id,
VehicleId = VehicleId,
Date = DateTime.Parse(Date),
Cost = Cost,
Mileage = Mileage,
Description = Description,
Notes = Notes,
Files = Files,
Tags = Tags,
ExtraFields = ExtraFields,
RequisitionHistory = RequisitionHistory
};
}
}
}

View File

@@ -19,5 +19,6 @@
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
}
}

View File

@@ -19,6 +19,7 @@
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public GasRecord ToGasRecord() { return new GasRecord {
Id = Id,
Cost = Cost,
@@ -30,7 +31,8 @@
IsFillToFull = IsFillToFull,
MissedFuelUp = MissedFuelUp,
Notes = Notes,
Tags = Tags
Tags = Tags,
ExtraFields = ExtraFields
}; }
}
}

View File

@@ -0,0 +1,9 @@
namespace CarCareTracker.Models
{
public class GenericRecordEditModel
{
public ImportMode DataType { get; set; }
public List<int> RecordIds { get; set; } = new List<int>();
public GenericRecord EditRecord { get; set; } = new GenericRecord();
}
}

View File

@@ -0,0 +1,14 @@
namespace CarCareTracker.Models
{
public class OpenIDConfig
{
public string Name { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string AuthURL { get; set; }
public string TokenURL { get; set; }
public string RedirectURL { get; set; }
public string Scope { get; set; }
public string RemoteAuthURL { get { return $"{AuthURL}?client_id={ClientId}&response_type=code&redirect_uri={RedirectURL}&scope={Scope}"; } }
}
}

View File

@@ -0,0 +1,7 @@
namespace CarCareTracker.Models
{
public class OpenIDResult
{
public string id_token { get; set; }
}
}

View File

@@ -9,5 +9,6 @@
public string Notes { get; set; }
public List<string> Tags { get; set; } = new List<string>();
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
}
}

View File

@@ -9,6 +9,7 @@
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public OdometerRecord ToOdometerRecord() { return new OdometerRecord { Id = Id, VehicleId = VehicleId, Date = DateTime.Parse(Date), Mileage = Mileage, Notes = Notes, Files = Files, Tags = Tags }; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public OdometerRecord ToOdometerRecord() { return new OdometerRecord { Id = Id, VehicleId = VehicleId, Date = DateTime.Parse(Date), Mileage = Mileage, Notes = Notes, Files = Files, Tags = Tags, ExtraFields = ExtraFields }; }
}
}

View File

@@ -1,8 +0,0 @@
namespace CarCareTracker.Models
{
public class PlanCostItem
{
public string CostName { get; set; }
public decimal CostAmount { get; set; }
}
}

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public DateTime DateCreated { get; set; }
public DateTime DateModified { get; set; }
public string Description { get; set; }
@@ -13,5 +14,7 @@
public PlanPriority Priority { get; set; }
public PlanProgress Progress { get; set; }
public decimal Cost { get; set; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
}
}

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public string DateCreated { get; set; } = DateTime.Now.ToShortDateString();
public string DateModified { get; set; } = DateTime.Now.ToShortDateString();
public string Description { get; set; }
@@ -14,9 +15,12 @@
public PlanPriority Priority { get; set; }
public PlanProgress Progress { get; set; }
public decimal Cost { get; set; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
public PlanRecord ToPlanRecord() { return new PlanRecord {
Id = Id,
VehicleId = VehicleId,
ReminderRecordId = ReminderRecordId,
DateCreated = DateTime.Parse(DateCreated),
DateModified = DateTime.Parse(DateModified),
Description = Description,
@@ -25,7 +29,13 @@
ImportMode = ImportMode,
Cost = Cost,
Priority = Priority,
Progress = Progress
Progress = Progress,
ExtraFields = ExtraFields,
RequisitionHistory = RequisitionHistory
}; }
/// <summary>
/// only used to hide view template button on plan create modal.
/// </summary>
public bool CreatedFromReminder { get; set; }
}
}

View File

@@ -5,5 +5,8 @@
public int MonthId { get; set; }
public string MonthName { get; set; }
public decimal Cost { get; set; }
public int MaxMileage { get; set; }
public int MinMileage { get; set; }
public int DistanceTraveled { get; set; }
}
}

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public string Date { get; set; } = DateTime.Now.ToShortDateString();
public int Mileage { get; set; }
public string Description { get; set; }
@@ -12,6 +13,21 @@
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<SupplyUsage> Supplies { get; set; } = new List<SupplyUsage>();
public List<string> Tags { get; set; } = new List<string>();
public ServiceRecord ToServiceRecord() { return new ServiceRecord { Id = Id, VehicleId = VehicleId, Date = DateTime.Parse(Date), Cost = Cost, Mileage = Mileage, Description = Description, Notes = Notes, Files = Files, Tags = Tags }; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
public ServiceRecord ToServiceRecord() { return new ServiceRecord {
Id = Id,
VehicleId = VehicleId,
Date = DateTime.Parse(Date),
Cost = Cost,
Mileage = Mileage,
Description = Description,
Notes = Notes,
Files = Files,
Tags = Tags,
ExtraFields = ExtraFields,
RequisitionHistory = RequisitionHistory
};
}
}
}

View File

@@ -0,0 +1,9 @@
namespace CarCareTracker.Models
{
public class ExtraField
{
public string Name { get; set; }
public string Value { get; set; }
public bool IsRequired { get; set; }
}
}

View File

@@ -11,5 +11,7 @@
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set;} = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
}
}

View File

@@ -35,6 +35,7 @@
public string Description { get; set; }
public string Cost { get; set; }
public string Notes { get; set; }
public string Tags { get; set; }
}
public class ServiceRecordExportModel

View File

@@ -0,0 +1,14 @@
using LiteDB;
namespace CarCareTracker.Models
{
public class RecordExtraField
{
/// <summary>
/// Corresponds to int value of ImportMode enum
/// </summary>
[BsonId(false)]
public int Id { get; set; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
}
}

View File

@@ -33,5 +33,8 @@
/// </summary>
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
}
}

View File

@@ -12,6 +12,9 @@
public decimal Cost { get; set; }
public string Notes { get; set; }
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
public SupplyRecord ToSupplyRecord() { return new SupplyRecord {
Id = Id,
VehicleId = VehicleId,
@@ -22,6 +25,10 @@
Quantity = Quantity,
Description = Description,
Notes = Notes,
Files = Files }; }
Files = Files,
Tags = Tags,
ExtraFields = ExtraFields,
RequisitionHistory = RequisitionHistory
}; }
}
}

View File

@@ -0,0 +1,10 @@
namespace CarCareTracker.Models
{
public class SupplyUsageHistory {
public DateTime Date { get; set; }
public string PartNumber { get; set; }
public string Description { get; set; }
public decimal Quantity { get; set; }
public decimal Cost { get; set; }
}
}

View File

@@ -13,5 +13,6 @@
public int CustomMonthInterval { get; set; } = 0;
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
}
}

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public string Date { get; set; } = DateTime.Now.ToShortDateString();
public string Description { get; set; }
public decimal Cost { get; set; }
@@ -13,6 +14,7 @@
public int CustomMonthInterval { get; set; } = 0;
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<string> Tags { get; set; } = new List<string>();
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public TaxRecord ToTaxRecord() { return new TaxRecord {
Id = Id,
VehicleId = VehicleId,
@@ -24,7 +26,8 @@
RecurringInterval = RecurringInterval,
CustomMonthInterval = CustomMonthInterval,
Files = Files,
Tags = Tags
Tags = Tags,
ExtraFields = ExtraFields
}; }
}
}

View File

@@ -4,6 +4,7 @@
{
public int Id { get; set; }
public int VehicleId { get; set; }
public int ReminderRecordId { get; set; }
public string Date { get; set; } = DateTime.Now.ToShortDateString();
public int Mileage { get; set; }
public string Description { get; set; }
@@ -12,6 +13,21 @@
public List<UploadedFiles> Files { get; set; } = new List<UploadedFiles>();
public List<SupplyUsage> Supplies { get; set; } = new List<SupplyUsage>();
public List<string> Tags { get; set; } = new List<string>();
public UpgradeRecord ToUpgradeRecord() { return new UpgradeRecord { Id = Id, VehicleId = VehicleId, Date = DateTime.Parse(Date), Cost = Cost, Mileage = Mileage, Description = Description, Notes = Notes, Files = Files, Tags = Tags }; }
public List<ExtraField> ExtraFields { get; set; } = new List<ExtraField>();
public List<SupplyUsageHistory> RequisitionHistory { get; set; } = new List<SupplyUsageHistory>();
public UpgradeRecord ToUpgradeRecord() { return new UpgradeRecord {
Id = Id,
VehicleId = VehicleId,
Date = DateTime.Parse(Date),
Cost = Cost,
Mileage = Mileage,
Description = Description,
Notes = Notes,
Files = Files,
Tags = Tags,
ExtraFields = ExtraFields,
RequisitionHistory = RequisitionHistory
};
}
}
}

View File

@@ -31,6 +31,7 @@ if (!string.IsNullOrWhiteSpace(builder.Configuration["POSTGRES_CONNECTION"])){
builder.Services.AddSingleton<IUserRecordDataAccess, PGUserRecordDataAccess>();
builder.Services.AddSingleton<ITokenRecordDataAccess, PGTokenRecordDataAccess>();
builder.Services.AddSingleton<IUserAccessDataAccess, PGUserAccessDataAccess>();
builder.Services.AddSingleton<IExtraFieldDataAccess, PGExtraFieldDataAccess>();
}
else
{
@@ -50,6 +51,7 @@ else
builder.Services.AddSingleton<IUserRecordDataAccess, UserRecordDataAccess>();
builder.Services.AddSingleton<ITokenRecordDataAccess, TokenRecordDataAccess>();
builder.Services.AddSingleton<IUserAccessDataAccess, UserAccessDataAccess>();
builder.Services.AddSingleton<IExtraFieldDataAccess, ExtraFieldDataAccess>();
}
//configure helpers

View File

@@ -1,39 +1,32 @@
![image](https://github.com/hargata/lubelog/assets/155338622/545debcd-d80a-44da-b892-4c652ab0384a)
A self-hosted, open-source vehicle service records and maintainence tracker.
A self-hosted, open-source vehicle service records and maintenance tracker.
Visit our website: https://lubelogger.com
Support this project by [Subscribing on Patreon](https://patreon.com/LubeLogger) or [Making a Donation](https://buy.stripe.com/aEU9Egc8DdMc9bO144)
## Why
Because nobody should have to deal with a homemade spreadsheet or a shoebox full of receipts when it comes to vehicle maintainence.
Note: Commercial users are required to maintain an active Patreon subscripton to be compliant with our licensing model.
## Screenshots
<a href="/docs/screenshots.md">Screenshots</a>
## Why
Because nobody should have to deal with a homemade spreadsheet or a shoebox full of receipts when it comes to vehicle maintenance.
## Showcase
[Promotional Brochure](https://lubelogger.com/brochure.pdf)
[Screenshots](/docs/screenshots.md)
## Demo
Try it out before you download it! The live demo resets every 20 minutes.
[Live Demo](https://demo.lubelogger.com) Login using username "test" and password "1234"
[Live Demo - Latest Stable](https://demo.lubelogger.com) Login using username "test" and password "1234"
## Dependencies
- Bootstrap
- LiteDB
- Bootstrap-DatePicker
- SweetAlert2
- CsvHelper
- Chart.js
- Drawdown
## Download
LubeLogger is distributed both as a Docker image or a Windows Standalone Executable.
## Docker Setup (GHCR)
1. Install Docker
2. Run `docker pull ghcr.io/hargata/lubelogger:latest`
3. CHECK culture in .env file, default is en_US, this will change the currency and date formats. You can also setup SMTP Config here.
4. If using traefik, use docker-compose.traefik.yml
5. Run `docker-compose up`
Read this [Getting Started Guide](https://docs.lubelogger.com/Getting%20Started) on how to download either of them
## Docker Setup (Manual Build)
### Docker Setup (Manual Build)
1. Install Docker
2. Clone this repo
3. CHECK culture in .env file, default is en_US, also setup SMTP for user management if you want that.
@@ -42,38 +35,12 @@ Try it out before you download it! The live demo resets every 20 minutes.
6. If using traefik, use docker-compose.traefik.yml
7. Run `docker-compose up`
## Additional Docker Instructions
### manual
- build
```
docker build -t hargata/lubelog:latest .
```
- run
```
docker run -d hargata/lubelog:latest
```
add `-v` for persistent volumes as needed. Have a look at the docker-compose.yml for examples.
## docker-compose
- build image
```
docker compose build
```
- run
```
docker compose up
# or variant with traefik labels:
docker compose -f docker-compose.traefik.yml up
```
## Dependencies
- Bootstrap
- LiteDB
- Npgsql
- Bootstrap-DatePicker
- SweetAlert2
- CsvHelper
- Chart.js
- Drawdown

View File

@@ -24,9 +24,12 @@
@if(config.GetServerEnableShopSupplies())
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="supply-tab" data-bs-toggle="tab" data-bs-target="#supply-tab-pane" type="button" role="tab"><span class="ms-2 display-3"><i class="bi bi-shop me-2"></i>@translator.Translate(userLanguage, "Supplies")</button>
<button class="nav-link" id="supply-tab" data-bs-toggle="tab" data-bs-target="#supply-tab-pane" type="button" role="tab"><span class="ms-2 display-3"><i class="bi bi-shop me-2"></i>@translator.Translate(userLanguage, "Supplies")</span></button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" id="calendar-tab" data-bs-toggle="tab" data-bs-target="#calendar-tab-pane" type="button" role="tab"><span class="ms-2 display-3"><i class="bi bi-calendar-week me-2"></i>@translator.Translate(userLanguage, "Calendar")</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(Model == "settings" ? "active" : "")" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-tab-pane" type="button" role="tab"><span class="ms-2 display-3"><i class="bi bi-gear me-2"></i>@translator.Translate(userLanguage,"Settings")</span></button>
</li>
@@ -64,6 +67,9 @@
<button class="nav-link" id="supply-tab" data-bs-toggle="tab" data-bs-target="#supply-tab-pane" type="button" role="tab" aria-selected="false"><i class="bi bi-shop me-2"></i>@translator.Translate(userLanguage, "Supplies")</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" id="calendar-tab" data-bs-toggle="tab" data-bs-target="#calendar-tab-pane" type="button" role="tab" aria-selected="false"><i class="bi bi-calendar-week me-2"></i>@translator.Translate(userLanguage, "Calendar")</button>
</li>
<li class="nav-item ms-auto" role="presentation">
<button class="nav-link @(Model == "settings" ? "active" : "")" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings-tab-pane" type="button" role="tab"><i class="bi bi-gear me-2"></i>@translator.Translate(userLanguage,"Settings")</button>
</li>
@@ -93,6 +99,8 @@
</div>
<div class="tab-pane fade" id="supply-tab-pane" role="tabpanel" tabindex="0">
</div>
<div class="tab-pane fade" id="calendar-tab-pane" role="tabpanel" tabindex="0">
</div>
<div class="tab-pane fade @(Model == "settings" ? "show active" : "")" id="settings-tab-pane" role="tabpanel" tabindex="0">
</div>
</div>

View File

@@ -0,0 +1,25 @@
@using CarCareTracker.Helper
@model List<ReminderRecordViewModel>
<script>
var eventDates = [];
var groupedDates = [];
@foreach(ReminderRecordViewModel reminderRecord in Model)
{
@:eventDates.push({ id: @reminderRecord.Id, date: decodeHTMLEntities('@StaticHelper.GetEpochFromDateTime(reminderRecord.Date)'), description: decodeHTMLEntities('@reminderRecord.Description'), urgency: decodeHTMLEntities('@reminderRecord.Urgency.ToString()') });
}
</script>
<div class="row vehicleDetailTabContainer">
<div class="col-12 reminderCalendarView">
<div class="reminderCalendarViewContent" style="height:60vh;">
</div>
</div>
</div>
<div class="modal fade" data-bs-focus="false" id="reminderRecordCalendarModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content" id="reminderRecordCalendarModalContent">
</div>
</div>
</div>
<script>
initCalendar();
</script>

View File

@@ -0,0 +1,124 @@
@using CarCareTracker.Helper
@inject IConfigHelper config
@inject ITranslationHelper translator
@{
var userConfig = config.GetUserConfig(User);
var userLanguage = userConfig.UserLanguage;
}
@model RecordExtraField
<script>
var extraFields = [];
</script>
<div class="modal-header">
<h5 class="modal-title">@(translator.Translate(userLanguage, "Manage Extra Fields"))</h5>
<button type="button" class="btn-close" onclick="hideExtraFieldModal()" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="form-group">
<div class="row">
<div class="col-12">
<select class="form-select" onchange="refreshExtraFieldModal()" id="extraFieldTab">
<!option @(Model.Id == (int)ImportMode.ServiceRecord ? "selected" : "") value="@((int)ImportMode.ServiceRecord)">@translator.Translate(userLanguage, "Service Record")</!option>
<!option @(Model.Id == (int)ImportMode.RepairRecord ? "selected" : "") value="@((int)ImportMode.RepairRecord)">@translator.Translate(userLanguage, "Repairs")</!option>
<!option @(Model.Id == (int)ImportMode.UpgradeRecord ? "selected" : "") value="@((int)ImportMode.UpgradeRecord)">@translator.Translate(userLanguage, "Upgrades")</!option>
<!option @(Model.Id == (int)ImportMode.GasRecord ? "selected" : "") value="@((int)ImportMode.GasRecord)">@translator.Translate(userLanguage, "Fuel")</!option>
<!option @(Model.Id == (int)ImportMode.TaxRecord ? "selected" : "") value="@((int)ImportMode.TaxRecord)">@translator.Translate(userLanguage, "Tax")</!option>
<!option @(Model.Id == (int)ImportMode.SupplyRecord ? "selected" : "") value="@((int)ImportMode.SupplyRecord)">@translator.Translate(userLanguage, "Supplies")</!option>
<!option @(Model.Id == (int)ImportMode.PlanRecord ? "selected" : "") value="@((int)ImportMode.PlanRecord)">@translator.Translate(userLanguage, "Planner")</!option>
<!option @(Model.Id == (int)ImportMode.OdometerRecord ? "selected" : "") value="@((int)ImportMode.OdometerRecord)">@translator.Translate(userLanguage, "Odometer")</!option>
</select>
</div>
</div>
<div class="row">
<div class="col-12">
<table class="table table-hover">
<thead class="sticky-top">
<tr class="d-flex">
<th scope="col" class="col-8">@translator.Translate(userLanguage, "Name")</th>
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Required")</th>
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Delete")</th>
</tr>
</thead>
<tbody>
@if (Model.ExtraFields != null)
{
@foreach (ExtraField extraField in Model.ExtraFields)
{
<script>
extraFields.push({ name: decodeHTMLEntities('@extraField.Name'), isRequired: @extraField.IsRequired.ToString().ToLower()});
</script>
<tr class="d-flex">
<td class="col-8">@extraField.Name</td>
<td class="col-2"><input class="form-check-input" type="checkbox" onchange="updateExtraFieldIsRequired(decodeHTMLEntities('@extraField.Name'), this)" value="" @(extraField.IsRequired ? "checked" : "") /></td>
<td class="col-2"><button type="button" onclick="deleteExtraField(decodeHTMLEntities('@extraField.Name'))" class="btn btn-danger"><i class="bi bi-trash"></i></button></td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideExtraFieldModal()">@translator.Translate(userLanguage, "Close")</button>
<button type="button" class="btn btn-primary" onclick="addExtraField()">@translator.Translate(userLanguage, "Add New Field")</button>
</div>
<script>
var importMode = @Model.Id;
function addExtraField() {
Swal.fire({
title: 'Field Name',
html: `
<input type="text" id="inputExtraFieldName" class="swal2-input" placeholder="Field Name">
`,
confirmButtonText: 'Add',
focusConfirm: false,
preConfirm: () => {
const extraFieldName = $("#inputExtraFieldName").val();
if (!extraFieldName || extraFieldName.trim() == '') {
Swal.showValidationMessage(`Field names cannot be blank`);
} else if (extraFields.filter(x => x.name == extraFieldName).length > 0) {
Swal.showValidationMessage(`Field names must be unique`);
}
return { extraFieldName }
},
}).then(function (result) {
if (result.isConfirmed) {
extraFields.push({ name: result.value.extraFieldName, isRequired: false});
updateExtraFields();
}
});
}
function updateExtraFieldIsRequired(fieldId, checkbox){
var indexToEdit = extraFields.findIndex(x => x.name == fieldId);
var extraFieldToEdit = extraFields[indexToEdit];
extraFieldToEdit.isRequired = $(checkbox).is(":checked");
updateExtraFields();
}
function deleteExtraField(fieldId) {
extraFields = extraFields.filter(x => x.name != fieldId);
updateExtraFields();
}
function refreshExtraFieldModal() {
var selectedExtraFieldTab = $("#extraFieldTab").val();
$.post(`/Home/GetExtraFieldsModal?importMode=${selectedExtraFieldTab}`, function (data) {
if (data) {
$("#extraFieldModalContent").html(data);
} else {
errorToast(genericErrorMessage());
}
});
}
function updateExtraFields() {
$.post('/Home/UpdateExtraFields', { id: importMode, extraFields: extraFields }, function (data) {
if (data) {
$("#extraFieldModalContent").html(data);
} else {
errorToast(genericErrorMessage());
}
});
}
</script>

View File

@@ -23,9 +23,9 @@
<div class="row gy-3 align-items-stretch vehiclesContainer">
@foreach (Vehicle vehicle in Model)
{
<div class="col-xl-2 col-lg-4 col-md-4 col-sm-4 col-4 user-select-none garage-item" data-tags='@string.Join(" ", vehicle.Tags)' id="gridVehicle_@vehicle.Id" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" data-bs-trigger="manual" onmouseenter="loadPinnedNotes(@vehicle.Id)" ontouchstart="loadPinnedNotes(@vehicle.Id)" ontouchcancel="hidePinnedNotes(@vehicle.Id)" ontouchend="hidePinnedNotes(@vehicle.Id)" onmouseleave="hidePinnedNotes(@vehicle.Id)">
<div class="col-xl-2 col-lg-4 col-md-4 col-sm-4 col-4 user-select-none garage-item" ondragover="dragOver(event)" ondrop="dropBox(event, @vehicle.Id)" draggable="true" ondragstart="dragStart(event, @vehicle.Id)" data-tags='@string.Join(" ", vehicle.Tags)' id="gridVehicle_@vehicle.Id" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" data-bs-trigger="manual" onmouseenter="loadPinnedNotes(@vehicle.Id)" ontouchstart="loadPinnedNotes(@vehicle.Id)" ontouchcancel="hidePinnedNotes(@vehicle.Id)" ontouchend="hidePinnedNotes(@vehicle.Id)" onmouseleave="hidePinnedNotes(@vehicle.Id)">
<div class="card" onclick="viewVehicle(@vehicle.Id)">
<img src="@vehicle.ImageLocation" style="height:145px; object-fit:scale-down;" />
<img src="@vehicle.ImageLocation" style="height:145px; object-fit:scale-down; pointer-events:none;" />
<div class="card-body">
<h5 class="card-title text-truncate garage-item-year" data-unit="@vehicle.Year">@($"{vehicle.Year}")</h5>
<h5 class="card-title text-truncate">@($"{vehicle.Make}")</h5>

View File

@@ -0,0 +1,39 @@
@using CarCareTracker.Helper
@inject IConfigHelper config
@inject ITranslationHelper translator
@model ReminderRecordViewModel
@{
var userConfig = config.GetUserConfig(User);
var userLanguage = userConfig.UserLanguage;
}
<div class="modal-header">
<h5 class="modal-title">@(translator.Translate(userLanguage, "View Reminder"))</h5>
<button type="button" class="btn-close" onclick="hideCalendarReminderModal()" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="form-group">
<div class="row">
<div class="col-md-6 col-12" id="reminderOptions">
<input type="text" id="workAroundInput" style="height:0px; width:0px; display:none;">
<label for="reminderDescription">@translator.Translate(userLanguage, "Date")</label>
<input type="text" id="reminderDescription" readonly class="form-control" value="@Model.Date.ToShortDateString()">
<label for="reminderDescription">@translator.Translate(userLanguage,"Description")</label>
<input type="text" id="reminderDescription" readonly class="form-control" value="@Model.Description">
</div>
<div class="col-md-6 col-12">
<label for="reminderNotes">@translator.Translate(userLanguage,"Notes")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
<textarea id="reminderNotes" readonly class="form-control" rows="5">@Model.Notes</textarea>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" onclick="deleteCalendarReminderRecord(@Model.Id)" style="margin-right:auto;">@translator.Translate(userLanguage, "Delete")</button>
<button type="button" class="btn btn-secondary" onclick="hideCalendarReminderModal()">@translator.Translate(userLanguage, "Close")</button>
@if (Model.IsRecurring && (Model.Urgency == ReminderUrgency.VeryUrgent || Model.Urgency == ReminderUrgency.PastDue))
{
<button type="button" class="btn btn-primary" onclick="markDoneCalendarReminderRecord(@Model.Id)">@translator.Translate(userLanguage, "Mark as Done")</button>
}
</div>

View File

@@ -178,6 +178,16 @@
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-6">
<span class="lead">@translator.Translate(userLanguage, "Manage Extra Fields")</span>
<div class="row">
<div class="col-12 d-grid">
<button onclick="showExtraFieldModal()" class="btn btn-primary btn-md">@translator.Translate(userLanguage, "Add/Remove Extra Fields")</button>
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -191,7 +201,7 @@
<img src="/defaults/lubelogger_logo.png" />
</div>
<div class="d-flex justify-content-center">
<small class="text-body-secondary">Version 1.1.5</small>
<small class="text-body-secondary">Version 1.2.2</small>
</div>
<p class="lead">
Proudly developed in the rural town of Price, Utah by Hargata Softworks.
@@ -229,7 +239,22 @@
</ul>
</div>
</div>
<div class="modal fade" data-bs-focus="false" id="extraFieldModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content" id="extraFieldModalContent">
</div>
</div>
</div>
<script>
function showExtraFieldModal() {
$.get(`/Home/GetExtraFieldsModal?importMode=0`, function (data) {
$("#extraFieldModalContent").html(data);
$("#extraFieldModal").modal('show');
});
}
function hideExtraFieldModal() {
$("#extraFieldModal").modal('hide');
}
function getCheckedTabs() {
var visibleTabs = $("#visibleTabs :checked").map(function () {
return this.value;
@@ -283,13 +308,13 @@
window.location.href = data;
});
}
function openUploadLanguage(){
function openUploadLanguage() {
$("#inputLanguage").click();
}
function openRestoreBackup() {
$("#inputBackup").click();
}
function uploadLanguage(event){
function uploadLanguage(event) {
let formData = new FormData();
formData.append("file", event.files[0]);
sloader.show();
@@ -308,7 +333,7 @@
errorToast(response.message);
}
},
error: function(){
error: function () {
sloader.hide();
errorToast("An error has occurred, please check the file size and try again later.");
}
@@ -351,9 +376,9 @@
Swal.fire({
title: 'Setup Credentials',
html: `
<input type="text" id="authUsername" class="swal2-input" placeholder="Username">
<input type="password" id="authPassword" class="swal2-input" placeholder="Password">
`,
<input type="text" id="authUsername" class="swal2-input" placeholder="Username">
<input type="password" id="authPassword" class="swal2-input" placeholder="Password">
`,
confirmButtonText: 'Setup',
focusConfirm: false,
preConfirm: () => {

View File

@@ -5,6 +5,7 @@
@{
var logoUrl = config.GetLogoUrl();
var userLanguage = config.GetServerLanguage();
var openIdConfigName = config.GetOpenIDConfig().Name;
}
@{
ViewData["Title"] = "LubeLogger - Login";
@@ -31,6 +32,12 @@
<div class="d-grid">
<button type="button" class="btn btn-warning mt-2" onclick="performLogin()"><i class="bi bi-box-arrow-in-right me-2"></i>@translator.Translate(userLanguage, "Login")</button>
</div>
@if (!string.IsNullOrWhiteSpace(openIdConfigName))
{
<div class="d-grid">
<button type="button" class="btn btn-secondary mt-2" onclick="remoteLogin()"><i class="bi bi-box-arrow-in-right me-2"></i>@($"{translator.Translate(userLanguage, "Login via")} {openIdConfigName}")</button>
</div>
}
<div class="d-grid">
<a href="/Login/ForgotPassword" class="btn btn-link mt-2">@translator.Translate(userLanguage, "Forgot Password")</a>
</div>
@@ -41,7 +48,7 @@
</div>
</div>
<script>
function getRedirectURL(){
function getRedirectURL() {
return { url: decodeHTMLEntities('@Model') };
}
</script>

View File

@@ -0,0 +1,50 @@
@using CarCareTracker.Helper
@inject IConfigHelper config
@inject ITranslationHelper translator
@{
var logoUrl = config.GetLogoUrl();
var userLanguage = config.GetServerLanguage();
}
@model string
@{
ViewData["Title"] = "LubeLogger - Register";
}
@section Scripts {
<script src="~/js/login.js"></script>
}
<div class="container d-flex align-items-center justify-content-center" style="height:100vh">
<div class="row">
<div class="col-12">
<img src="@logoUrl" />
<div class="form-group">
<label for="inputToken">@translator.Translate(userLanguage, "Token")</label>
<input type="text" id="inputToken" class="form-control">
</div>
<div class="form-group">
<label for="inputUserName">@translator.Translate(userLanguage, "Username")</label>
<input type="text" id="inputUserName" class="form-control" value="@Model">
</div>
<div class="d-grid">
<button type="button" class="btn btn-warning mt-2" onclick="performOpenIdRegistration()"><i class="bi bi-box-arrow-in-right me-2"></i>@translator.Translate(userLanguage, "Register")</button>
</div>
<div class="d-grid">
<a href="/Login/Index" class="btn btn-link mt-2">@translator.Translate(userLanguage, "Back to Login")</a>
</div>
</div>
</div>
</div>
<script>
function performOpenIdRegistration() {
var token = $("#inputToken").val();
var userName = $("#inputUserName").val();
var userEmail = decodeHTMLEntities('@Model');
$.post('/Login/RegisterOpenIdUser', { userName: userName, token: token, emailAddress: userEmail }, function (data) {
if (data.success) {
successToast(data.message);
setTimeout(function () { window.location.href = '/Home' }, 500);
} else {
errorToast(data.message);
}
});
}
</script>

View File

@@ -10,6 +10,7 @@
var useMarkDown = userConfig.UseMarkDownOnSavedNotes;
var useThreeDecimals = userConfig.UseThreeDecimalGasCost;
var shortDatePattern = System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern;
var firstDayOfWeek = (int)System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
var numberFormat = System.Globalization.CultureInfo.CurrentCulture.NumberFormat;
var userLanguage = userConfig.UserLanguage;
shortDatePattern = shortDatePattern.ToLower();
@@ -58,7 +59,8 @@
useMarkDown: "@useMarkDown" == "True",
currencySymbol: decodeHTMLEntities("@numberFormat.CurrencySymbol"),
useThreeDecimals: "@useThreeDecimals" == "True",
useMPG: "@useMPG" == "True"
useMPG: "@useMPG" == "True",
firstDayOfWeek: @firstDayOfWeek
}
}
function getShortDatePattern() {

View File

@@ -156,6 +156,12 @@
</div>
</div>
</div>
<div class="modal fade" data-bs-focus="false" id="genericRecordEditModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content" id="genericRecordEditModalContent">
</div>
</div>
</div>
<div class="modal fade" data-bs-focus="false" id="inputSuppliesModal" tabindex="-1" role="dialog" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content" id="inputSuppliesModalContent"></div>

View File

@@ -23,11 +23,19 @@
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="collisionRecordMileage">@translator.Translate(userLanguage,"Odometer")</label>
<input type="number" id="collisionRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when repaired")" value="@(isNew ? "" : Model.Mileage)">
<input type="number" inputmode="numeric" id="collisionRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when repaired")" value="@(isNew ? "" : Model.Mileage)">
<label for="collisionRecordDescription">@translator.Translate(userLanguage,"Description")</label>
<input type="text" id="collisionRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage,"Description of item(s) repaired(i.e. Alternator)")" value="@Model.Description">
@if (isNew)
{
<div class="row">
<div class="col-12">
<a onclick="showRecurringReminderSelector('collisionRecordDescription')" class="btn btn-link">@translator.Translate(userLanguage, "Select Reminder")</a>
</div>
</div>
}
<label for="collisionRecordCost">@translator.Translate(userLanguage,"Cost")</label>
<input type="text" id="collisionRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the repair")" value="@(isNew ? "" : Model.Cost)">
<input type="text" inputmode="decimal" id="collisionRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the repair")" value="@(isNew ? "" : Model.Cost)">
@if (isNew)
{
@await Html.PartialAsync("_SupplyStore", "RepairRecord")
@@ -39,6 +47,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="collisionRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
@@ -75,6 +91,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.RequisitionHistory.Any())
{
<button type="button" class="btn btn-warning" onclick="toggleSupplyUsageHistory()"><i class="bi bi-shop"></i></button>
}
<div class="btn-group" style="margin-right:auto;">
<button type="button" class="btn btn-md mt-1 mb-1 btn-danger" onclick="deleteCollisionRecord(@Model.Id)">@translator.Translate(userLanguage,"Delete")</button>
<button type="button" class="btn btn-md btn-danger btn-md mt-1 mb-1 dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
@@ -97,16 +117,18 @@
<button type="button" class="btn btn-primary" onclick="saveCollisionRecordToVehicle(true)">@translator.Translate(userLanguage,"Edit Repair Record")</button>
}
</div>
@await Html.PartialAsync("_SupplyRequisitionHistory", Model.RequisitionHistory)
<script>
var uploadedFiles = [];
var selectedSupplies = [];
var recurringReminderRecordId = 0;
getUploadedFilesFromModel();
function getUploadedFilesFromModel() {
@foreach (UploadedFiles filesUploaded in Model.Files)
{
@:uploadedFiles.push({ name: "@filesUploaded.Name", location: "@filesUploaded.Location" });
}
}
}
function getCollisionRecordModelData() {
return { id: @Model.Id}
}

View File

@@ -68,7 +68,7 @@
<tbody>
@foreach (CollisionRecord collisionRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditCollisionRecordModal(@collisionRecord.Id)" data-tags='@string.Join(" ", collisionRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@collisionRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditCollisionRecordModal,@collisionRecord.Id)" data-tags='@string.Join(" ", collisionRecord.Tags)'>
<td class="col-2 col-xl-1">@collisionRecord.Date.ToShortDateString()</td>
<td class="col-2">@collisionRecord.Mileage</td>
<td class="col-3 col-xl-4">@collisionRecord.Description</td>
@@ -87,4 +87,17 @@
<div class="modal-content" id="collisionRecordModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><a class="context-menu-active-multiple dropdown-item" href="#" onclick="editMultipleRecords(selectedRow, 'RepairRecord')">@translator.Translate(userLanguage, "Edit Multiple")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><h6 class="dropdown-header">@translator.Translate(userLanguage, "Move To")</h6></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'RepairRecord', 'ServiceRecord')">@translator.Translate(userLanguage, "Service Records")</a></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'RepairRecord', 'UpgradeRecord')">@translator.Translate(userLanguage, "Upgrades")</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'RepairRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'RepairRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -112,7 +112,7 @@
<tbody>
@foreach (GasRecordViewModel gasRecord in Model.GasRecords)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditGasRecordModal(@gasRecord.Id)" data-tags='@string.Join(" ", gasRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@gasRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditGasRecordModal,@gasRecord.Id)" data-tags='@string.Join(" ", gasRecord.Tags)'>
<td class="col-2">@gasRecord.Date</td>
<td class="col-2" data-gas-type="mileage" data-gas-aggregate="@gasRecord.DeltaMileage" data-gas-original="@gasRecord.Mileage">@gasRecord.Mileage</td>
<td class="col-1">@(gasRecord.DeltaMileage == default ? "---" : gasRecord.DeltaMileage)</td>
@@ -136,6 +136,14 @@
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'GasRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'GasRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>
<script>
@if (!string.IsNullOrWhiteSpace(preferredFuelEconomyUnit))
{

View File

@@ -8,7 +8,7 @@
var barGraphColors = new string[] { "#00876c", "#43956e", "#67a371", "#89b177", "#a9be80", "#c8cb8b", "#e6d79b", "#e4c281", "#e3ab6b", "#e2925b", "#e07952", "#db5d4f" };
var sortedByMPG = Model.OrderBy(x => x.Cost).ToList();
}
@if (Model.Where(x=>x.Cost > 0).Any())
@if (Model.Where(x=>x.Cost > 0).Any() || Model.Where(x=>x.DistanceTraveled > 0).Any())
{
<canvas id="bar-chart"></canvas>
<script>
@@ -16,6 +16,7 @@
function renderChart() {
var barGraphLabels = [];
var barGraphData = [];
var lineChartData = [];
//color gradient from high to low
var barGraphColors = [];
var useDarkMode = getGlobalConfig().useDarkMode;
@@ -23,6 +24,7 @@
{
@:barGraphLabels.push(decodeHTMLEntities("@gasCost.MonthName"));
@:barGraphData.push(globalParseFloat('@gasCost.Cost'));
@:lineChartData.push(globalParseFloat('@gasCost.DistanceTraveled'));
var index = sortedByMPG.FindIndex(x => x.MonthName == gasCost.MonthName);
@:barGraphColors.push('@barGraphColors[index]');
}
@@ -34,7 +36,18 @@
{
label: decodeHTMLEntities('@translator.Translate(userLanguage, "Expenses by Month")'),
backgroundColor: barGraphColors,
data: barGraphData
data: barGraphData,
order: 1,
yAxisID: 'y',
},
{
label: decodeHTMLEntities('@translator.Translate(userLanguage, "Distance Traveled by Month")'),
data: lineChartData,
borderColor: useDarkMode ? "#fff" : "#000",
backgroundColor: useDarkMode ? "#000" : "#fff",
type: 'line',
order: 0,
yAxisID: 'y1',
}
]
},
@@ -43,7 +56,7 @@
title: {
display: true,
color: useDarkMode ? "#fff" : "#000",
text: decodeHTMLEntities('@translator.Translate(userLanguage, "Expenses by Month")')
text: decodeHTMLEntities('@translator.Translate(userLanguage, "Expenses and Distance Traveled by Month")')
},
legend: {
display: false,
@@ -59,6 +72,13 @@
color: useDarkMode ? "#fff" : "#000"
}
},
y1: {
beginAtZero: true,
position: 'right',
ticks: {
color: useDarkMode ? "#fff" : "#000"
}
},
x: {
ticks: {
color: useDarkMode ? "#fff" : "#000"

View File

@@ -52,9 +52,9 @@
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="gasRecordMileage">@($"{translator.Translate(userLanguage,"Odometer Reading")}({distanceUnit})")</label>
<input type="number" id="gasRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when refueled")" value="@(isNew ? "" : Model.GasRecord.Mileage)">
<input type="number" inputmode="numeric" id="gasRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when refueled")" value="@(isNew ? "" : Model.GasRecord.Mileage)">
<label for="gasRecordGallons">@($"{translator.Translate(userLanguage, "Fuel Consumption")}({consumptionUnit})")</label>
<input type="text" id="gasRecordGallons" class="form-control" placeholder="@translator.Translate(userLanguage,"Amount of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Gallons)">
<input type="text" inputmode="decimal" id="gasRecordGallons" class="form-control" placeholder="@translator.Translate(userLanguage,"Amount of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Gallons)">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="gasIsFillToFull" checked="@Model.GasRecord.IsFillToFull">
<label class="form-check-label" for="gasIsFillToFull">@translator.Translate(userLanguage,"Is Filled To Full")</label>
@@ -67,7 +67,7 @@
@if (isNew)
{
<div class="input-group">
<input type="text" id="gasRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Cost)">
<input type="text" inputmode="decimal" id="gasRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Cost)">
<div class="input-group-text">
<select class="form-select form-select-sm" id="gasCostType">
<option value="total">@translator.Translate(userLanguage,"Total")</option>
@@ -77,7 +77,7 @@
</div>
} else
{
<input type="text" id="gasRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Cost)">
<input type="text" inputmode="decimal" id="gasRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of gas refueled")" value="@(isNew ? "" : Model.GasRecord.Cost)">
}
<label for="gasRecordTag">@translator.Translate(userLanguage,"Tags(optional)")</label>
<select multiple class="form-select" id="gasRecordTag">
@@ -86,6 +86,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.GasRecord.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="gasRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>

View File

@@ -0,0 +1,55 @@
@using CarCareTracker.Helper
@inject IConfigHelper config
@inject ITranslationHelper translator
@model GenericRecordEditModel
@{
var userConfig = config.GetUserConfig(User);
var userLanguage = userConfig.UserLanguage;
}
<div class="modal-header">
<h5 class="modal-title">@translator.Translate(userLanguage,"Edit Multiple Records")</h5>
<button type="button" class="btn-close" onclick="hideGenericRecordModal()" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="form-group">
<div class="row">
<div class="col-md-6 col-12">
<label for="genericRecordDate">@translator.Translate(userLanguage,"Date")</label>
<div class="input-group">
<input type="text" id="genericRecordDate" class="form-control" placeholder="@translator.Translate(userLanguage,"(multiple)")">
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="genericRecordMileage">@translator.Translate(userLanguage,"Odometer")</label>
<input type="number" inputmode="numeric" id="genericRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"(multiple)")">
<label for="genericRecordDescription">@translator.Translate(userLanguage, "Description")</label>
<input type="text" id="genericRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage,"(multiple)")">
<label for="genericRecordCost">@translator.Translate(userLanguage, "Cost")</label>
<input type="text" inputmode="decimal" id="genericRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"(multiple)")">
<label for="genericRecordTag">@translator.Translate(userLanguage, "Tags(use --- to clear all existing tags)")</label>
<select multiple class="form-select" id="genericRecordTag"></select>
</div>
<div class="col-md-6 col-12">
<label for="genericRecordNotes">@translator.Translate(userLanguage, "Notes(use --- to clear all existing notes)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
<textarea id="genericRecordNotes" class="form-control" rows="5"></textarea>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideGenericRecordModal()">@translator.Translate(userLanguage, "Cancel")</button>
<button type="button" class="btn btn-primary" onclick="saveGenericRecord()">@translator.Translate(userLanguage,"Edit")</button>
</div>
<script>
var recordsToEdit = [];
@foreach(int recordId in Model.RecordIds)
{
@:recordsToEdit.push(@recordId);
}
function getGenericRecordEditModelData(){
return {
dataType: decodeHTMLEntities('@Model.DataType.ToString()')
}
}
</script>

View File

@@ -44,7 +44,7 @@
<tbody>
@foreach (Note note in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditNoteModal(@note.Id)" data-tags='@string.Join(" ", note.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@note.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditNoteModal,@note.Id)" data-tags='@string.Join(" ", note.Tags)'>
@if (note.Pinned)
{
<td class="col-3"><i class='bi bi-pin-fill me-2'></i>@note.Description</td>
@@ -66,4 +66,16 @@
<div class="modal-content" id="noteModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="pinNotes(selectedRow, true)">@translator.Translate(userLanguage, "Toggle Pin")</a></li>
<li><a class="dropdown-item" href="#" onclick="pinNotes(selectedRow, false, true)">@translator.Translate(userLanguage, "Pin")</a></li>
<li><a class="dropdown-item" href="#" onclick="pinNotes(selectedRow, false, false)">@translator.Translate(userLanguage, "Unpin")</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'NoteRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'NoteRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -23,7 +23,7 @@
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="odometerRecordMileage">@translator.Translate(userLanguage,"Odometer")</label>
<input type="number" id="odometerRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading")" value="@(isNew ? "" : Model.Mileage)">
<input type="number" inputmode="numeric" id="odometerRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading")" value="@(isNew ? "" : Model.Mileage)">
<label for="odometerRecordTag">@translator.Translate(userLanguage,"Tags(optional)")</label>
<select multiple class="form-select" id="odometerRecordTag">
@foreach (string tag in Model.Tags)
@@ -31,6 +31,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="odometerRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>

View File

@@ -12,7 +12,7 @@
<div class="row">
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center flex-wrap">
<span class="ms-2 badge bg-success" data-aggregate-type="count">@($"{translator.Translate(userLanguage,"# of Odometer Records")}: {Model.Count()}")</span>
<span class="ms-2 badge bg-success" data-aggregate-type="count">@($"{translator.Translate(userLanguage, "# of Odometer Records")}: {Model.Count()}")</span>
@foreach (string recordTag in recordTags)
{
<span onclick="filterTable('odometer-tab-pane', this)" class="user-select-none ms-2 rounded-pill badge bg-secondary tagfilter" style="cursor:pointer;">@recordTag</span>
@@ -28,21 +28,21 @@
@if (enableCsvImports)
{
<div class="btn-group">
<button onclick="showAddOdometerRecordModal()" class="btn btn-primary btn-md mt-1 mb-1"><i class="bi bi-pencil-square me-2"></i>@translator.Translate(userLanguage,"Add Odometer Record")</button>
<button onclick="showAddOdometerRecordModal()" class="btn btn-primary btn-md mt-1 mb-1"><i class="bi bi-pencil-square me-2"></i>@translator.Translate(userLanguage, "Add Odometer Record")</button>
<button type="button" class="btn btn-md btn-primary btn-md mt-1 mb-1 dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="showBulkImportModal('OdometerRecord')">@translator.Translate(userLanguage,"Import via CSV")</a></li>
<li><a class="dropdown-item" href="#" onclick="exportVehicleData('OdometerRecord')">@translator.Translate(userLanguage,"Export to CSV")</a></li>
<li><a class="dropdown-item" href="#" onclick="showBulkImportModal('OdometerRecord')">@translator.Translate(userLanguage, "Import via CSV")</a></li>
<li><a class="dropdown-item" href="#" onclick="exportVehicleData('OdometerRecord')">@translator.Translate(userLanguage, "Export to CSV")</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="printTab()">@translator.Translate(userLanguage,"Print")</a></li>
<li><a class="dropdown-item" href="#" onclick="printTab()">@translator.Translate(userLanguage, "Print")</a></li>
</ul>
</div>
}
else
{
<button onclick="showAddOdometerRecordModal()" class="btn btn-primary btn-md mt-1 mb-1"><i class="bi bi-pencil-square me-2"></i>@translator.Translate(userLanguage,"Add Odometer Record")</button>
<button onclick="showAddOdometerRecordModal()" class="btn btn-primary btn-md mt-1 mb-1"><i class="bi bi-pencil-square me-2"></i>@translator.Translate(userLanguage, "Add Odometer Record")</button>
}
</div>
</div>
@@ -57,15 +57,15 @@
<table class="table table-hover">
<thead class="sticky-top">
<tr class="d-flex">
<th scope="col" class="col-2 col-xl-1">@translator.Translate(userLanguage,"Date")</th>
<th scope="col" class="col-3">@translator.Translate(userLanguage,"Odometer")</th>
<th scope="col" class="col-7 col-xl-8">@translator.Translate(userLanguage,"Notes")</th>
<th scope="col" class="col-2 col-xl-1">@translator.Translate(userLanguage, "Date")</th>
<th scope="col" class="col-3">@translator.Translate(userLanguage, "Odometer")</th>
<th scope="col" class="col-7 col-xl-8">@translator.Translate(userLanguage, "Notes")</th>
</tr>
</thead>
<tbody>
@foreach (OdometerRecord odometerRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditOdometerRecordModal(@odometerRecord.Id)" data-tags='@string.Join(" ", odometerRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@odometerRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditOdometerRecordModal,@odometerRecord.Id)" data-tags='@string.Join(" ", odometerRecord.Tags)'>
<td class="col-2 col-xl-1">@odometerRecord.Date.ToShortDateString()</td>
<td class="col-3" data-record-type="cost">@odometerRecord.Mileage</td>
<td class="col-7 col-xl-8 text-truncate">@CarCareTracker.Helper.StaticHelper.TruncateStrings(odometerRecord.Notes, 75)</td>
@@ -82,4 +82,12 @@
<div class="modal-content" id="odometerRecordModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'OdometerRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'OdometerRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -16,7 +16,13 @@
</div>
</div>
<div class="row">
<div class="col-6 col-md-1">
@if (Model.ReminderRecordId != default)
{
<div class="col-4 col-md-1">
<i class="bi bi-bell"></i>
</div>
}
<div class="@(Model.ReminderRecordId != default ? "col-4" : "col-6") col-md-1">
@if (Model.ImportMode == ImportMode.ServiceRecord)
{
<i class="bi bi-card-checklist"></i>
@@ -30,7 +36,7 @@
<i class="bi bi-exclamation-octagon"></i>
}
</div>
<div class="col-6 col-md-1">
<div class="@(Model.ReminderRecordId != default ? "col-4" : "col-6") col-md-1">
@if (Model.Priority == PlanPriority.Critical)
{
<i class="bi bi-fire"></i>

View File

@@ -20,7 +20,7 @@
<label for="planRecordDescription">@translator.Translate(userLanguage, "Description")</label>
<input type="text" id="planRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage, "Describe the Plan")" value="@Model.Description">
<label for="planRecordCost">@translator.Translate(userLanguage, "Cost")</label>
<input type="text" id="planRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage, "Cost of the Plan")" value="@Model.Cost">
<input type="text" inputmode="decimal" id="planRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage, "Cost of the Plan")" value="@Model.Cost">
@if (isNew)
{
@await Html.PartialAsync("_SupplyStore", "PlanRecord")
@@ -43,6 +43,14 @@
<!option value="InProgress" @(Model.Progress == PlanProgress.InProgress ? "selected" : "")>@translator.Translate(userLanguage, "Doing")</!option>
<!option value = "Testing" @(Model.Progress == PlanProgress.Testing ? "selected" : "")>@translator.Translate(userLanguage, "Testing")</!option>
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
@if (!isNew)
{
<label>@($"{translator.Translate(userLanguage, "Date Created")}: {Model.DateCreated}")</label>
@@ -77,6 +85,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.RequisitionHistory.Any())
{
<button type="button" class="btn btn-warning" onclick="toggleSupplyUsageHistory()"><i class="bi bi-shop"></i></button>
}
<button type="button" class="btn btn-danger" onclick="deletePlanRecord(@Model.Id)" style="margin-right:auto;">@translator.Translate(userLanguage, "Delete")</button>
}
<button type="button" class="btn btn-secondary" onclick="hideAddPlanRecordModal()">@translator.Translate(userLanguage, "Cancel")</button>
@@ -89,7 +101,10 @@
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="savePlanRecordTemplate()">@translator.Translate(userLanguage, "Save as Template")</a></li>
<li><a class="dropdown-item" href="#" onclick="showPlanRecordTemplatesModal()">@translator.Translate(userLanguage, "View Templates")</a></li>
@if (!Model.CreatedFromReminder)
{
<li><a class="dropdown-item" href="#" onclick="showPlanRecordTemplatesModal()">@translator.Translate(userLanguage, "View Templates")</a></li>
}
</ul>
</div>
}
@@ -98,6 +113,7 @@
<button type="button" class="btn btn-primary" onclick="savePlanRecordToVehicle(true)">@translator.Translate(userLanguage, "Edit Plan Record")</button>
}
</div>
@await Html.PartialAsync("_SupplyRequisitionHistory", Model.RequisitionHistory)
<script>
var uploadedFiles = [];
var selectedSupplies = [];
@@ -111,7 +127,9 @@
function getPlanRecordModelData() {
return {
id: @Model.Id,
dateCreated: decodeHTMLEntities('@(Model.DateCreated)')
dateCreated: decodeHTMLEntities('@(Model.DateCreated)'),
reminderRecordId: decodeHTMLEntities('@Model.ReminderRecordId'),
createdFromReminder: @Model.CreatedFromReminder.ToString().ToLower()
}
}
</script>

View File

@@ -29,6 +29,10 @@
<tr class="d-flex" id="supplyRows">
<td class="col-8 text-truncate">
@StaticHelper.TruncateStrings(planRecordTemplate.Description)
@if(planRecordTemplate.ReminderRecordId != default)
{
<i class="bi bi-bell ms-2"></i>
}
@if (planRecordTemplate.Files.Any())
{
<i class="bi bi-paperclip ms-2"></i>

View File

@@ -0,0 +1,13 @@
@model List<ReminderRecord>
<select class="form-select" id="recurringReminderInput">
@if (Model.Any())
{
@foreach (ReminderRecord reminderRecord in Model)
{
<!option value="@reminderRecord.Id">@reminderRecord.Description</!option>
}
} else
{
<!option value="0">No Recurring Reminders Found</!option>
}
</select>

View File

@@ -33,9 +33,9 @@
<label class="form-check-label" for="reminderMetricOdometer">@translator.Translate(userLanguage,"Odometer")</label>
</div>
<div class="input-group">
<input type="number" id="reminderMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Future Odometer Reading")" value="@(isNew ? "" : Model.Mileage)">
<input type="number" inputmode="numeric" id="reminderMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Future Odometer Reading")" value="@(isNew ? "" : Model.Mileage)">
<div class="input-group-text">
<button type="button" class="btn btn-sm btn-primary" onclick="appendMileageToOdometer(500)">+500</button>
<button type="button" class="btn btn-sm btn-primary zero-y-padding" onclick="appendMileageToOdometer(500)">+500</button>
</div>
</div>
<div class="form-check form-check-inline">
@@ -90,6 +90,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.IsRecurring)
{
<button type="button" class="btn btn-warning" onclick="createPlanRecordFromReminder(@Model.Id)">@translator.Translate(userLanguage, "Plan")</button>
}
<button type="button" class="btn btn-danger" onclick="deleteReminderRecord(@Model.Id)" style="margin-right:auto;">@translator.Translate(userLanguage, "Delete")</button>
}
<button type="button" class="btn btn-secondary" onclick="hideAddReminderRecordModal()">@translator.Translate(userLanguage, "Cancel")</button>

View File

@@ -45,7 +45,7 @@
<tbody>
@foreach (ReminderRecordViewModel reminderRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditReminderRecordModal(@reminderRecord.Id)">
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@reminderRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditReminderRecordModal,@reminderRecord.Id)">
@if (reminderRecord.Urgency == ReminderUrgency.VeryUrgent)
{
<td class="col-1"><span class="badge text-bg-danger">@translator.Translate(userLanguage, "Very Urgent")</span></td>
@@ -93,4 +93,18 @@
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" data-bs-focus="false" id="planRecordModal" tabindex="-1" role="dialog" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content" id="planRecordModalContent">
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'ReminderRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'ReminderRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -29,26 +29,55 @@
<div class="col-md-6 col-12 mt-2">
<div class="row">
<div class="col-md-1 d-sm-none d-md-block"></div>
<div class="col-12 col-md-10 reportsCheckBoxContainer">
<div class="form-check form-check-inline">
<input class="form-check-input" onChange="updateCheck()" type="checkbox" id="serviceExpenseCheck" value="1" checked>
<label class="form-check-label" for="serviceExpenseCheck">@translator.Translate(userLanguage,"Service")</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" onChange="updateCheck()" type="checkbox" id="repairExpenseCheck" value="2" checked>
<label class="form-check-label" for="repairExpenseCheck">@translator.Translate(userLanguage, "Repairs")</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" onChange="updateCheck()" type="checkbox" id="upgradeExpenseCheck" value="3" checked>
<label class="form-check-label" for="upgradeExpenseCheck">@translator.Translate(userLanguage, "Upgrades")</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" onChange="updateCheck()" type="checkbox" id="gasExpenseCheck" value="4" checked>
<label class="form-check-label" for="gasExpenseCheck">@translator.Translate(userLanguage, "Fuel")</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" onChange="updateCheck()" type="checkbox" id="taxExpenseCheck" value="5" checked>
<label class="form-check-label" for="taxExpenseCheck">@translator.Translate(userLanguage, "Taxes")</label>
<div class="col-12 col-md-10">
<div class="dropdown d-grid dropdown-center">
<button class="btn btn-outline-warning dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
Metrics
</button>
<ul class="dropdown-menu" style="width:100%;">
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input" onChange="updateCheckAll()" type="checkbox" id="selectAllExpenseCheck" value="0" checked>
<label class="form-check-label stretched-link" for="selectAllExpenseCheck">@translator.Translate(userLanguage, "Select All")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="odometerExpenseCheck" value="6" checked>
<label class="form-check-label stretched-link" for="odometerExpenseCheck">@translator.Translate(userLanguage, "Odometer")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="serviceExpenseCheck" value="1" checked>
<label class="form-check-label stretched-link" for="serviceExpenseCheck">@translator.Translate(userLanguage, "Service")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="repairExpenseCheck" value="2" checked>
<label class="form-check-label stretched-link" for="repairExpenseCheck">@translator.Translate(userLanguage, "Repairs")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="upgradeExpenseCheck" value="3" checked>
<label class="form-check-label stretched-link" for="upgradeExpenseCheck">@translator.Translate(userLanguage, "Upgrades")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="gasExpenseCheck" value="4" checked>
<label class="form-check-label stretched-link" for="gasExpenseCheck">@translator.Translate(userLanguage, "Fuel")</label>
</div>
</li>
<li class="dropdown-item">
<div class="list-group-item">
<input class="form-check-input reportCheckBox" onChange="updateCheck()" type="checkbox" id="taxExpenseCheck" value="5" checked>
<label class="form-check-label stretched-link" for="taxExpenseCheck">@translator.Translate(userLanguage, "Taxes")</label>
</div>
</li>
</ul>
</div>
</div>
<div class="col-md-1 d-sm-none d-md-block"></div>

View File

@@ -23,11 +23,19 @@
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="serviceRecordMileage">@translator.Translate(userLanguage,"Odometer")</label>
<input type="number" id="serviceRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when serviced")" value="@(isNew ? "" : Model.Mileage)">
<input type="number" inputmode="numeric" id="serviceRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when serviced")" value="@(isNew ? "" : Model.Mileage)">
<label for="serviceRecordDescription">@translator.Translate(userLanguage,"Description")</label>
<input type="text" id="serviceRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage,"Description of item(s) serviced(i.e. Oil Change)")" value="@Model.Description">
@if (isNew)
{
<div class="row">
<div class="col-12">
<a onclick="showRecurringReminderSelector('serviceRecordDescription')" class="btn btn-link">@translator.Translate(userLanguage, "Select Reminder")</a>
</div>
</div>
}
<label for="serviceRecordCost">@translator.Translate(userLanguage,"Cost")</label>
<input type="text" id="serviceRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the service")" value="@(isNew ? "" : Model.Cost)">
<input type="text" inputmode="decimal" id="serviceRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the service")" value="@(isNew ? "" : Model.Cost)">
@if (isNew)
{
@await Html.PartialAsync("_SupplyStore", "ServiceRecord")
@@ -39,6 +47,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="serviceRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
@@ -75,6 +91,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.RequisitionHistory.Any())
{
<button type="button" class="btn btn-warning" onclick="toggleSupplyUsageHistory()"><i class="bi bi-shop"></i></button>
}
<div class="btn-group" style="margin-right:auto;">
<button type="button" class="btn btn-md mt-1 mb-1 btn-danger" onclick="deleteServiceRecord(@Model.Id)">@translator.Translate(userLanguage,"Delete")</button>
<button type="button" class="btn btn-md btn-danger btn-md mt-1 mb-1 dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
@@ -97,9 +117,11 @@
<button type="button" class="btn btn-primary" onclick="saveServiceRecordToVehicle(true)">@translator.Translate(userLanguage,"Edit Service Record")</button>
}
</div>
@await Html.PartialAsync("_SupplyRequisitionHistory", Model.RequisitionHistory)
<script>
var uploadedFiles = [];
var selectedSupplies = [];
var recurringReminderRecordId = 0;
getUploadedFilesFromModel();
function getUploadedFilesFromModel() {
@foreach (UploadedFiles filesUploaded in Model.Files)

View File

@@ -68,7 +68,7 @@
<tbody>
@foreach (ServiceRecord serviceRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditServiceRecordModal(@serviceRecord.Id)" data-tags='@string.Join(" ",serviceRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@serviceRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditServiceRecordModal,@serviceRecord.Id)" data-tags='@string.Join(" ", serviceRecord.Tags)'>
<td class="col-2 col-xl-1">@serviceRecord.Date.ToShortDateString()</td>
<td class="col-2">@serviceRecord.Mileage</td>
<td class="col-3 col-xl-4">@serviceRecord.Description</td>
@@ -88,4 +88,17 @@
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><a class="context-menu-active-multiple dropdown-item" href="#" onclick="editMultipleRecords(selectedRow, 'ServiceRecord')">@translator.Translate(userLanguage, "Edit Multiple")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><h6 class="dropdown-header">@translator.Translate(userLanguage, "Move To")</h6></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'ServiceRecord', 'RepairRecord')">@translator.Translate(userLanguage, "Repairs")</a></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'ServiceRecord', 'UpgradeRecord')">@translator.Translate(userLanguage, "Upgrades")</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'ServiceRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'ServiceRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -31,13 +31,33 @@
<div class="row">
<div class="col-md-6 col-12">
<label for="supplyRecordQuantity">@translator.Translate(userLanguage,"Quantity")</label>
<input type="text" id="supplyRecordQuantity" class="form-control" placeholder="@translator.Translate(userLanguage,"Quantity")" value="@(isNew ? "1" : Model.Quantity)">
<div class="input-group">
<input type="text" inputmode="decimal" id="supplyRecordQuantity" class="form-control" placeholder="@translator.Translate(userLanguage,"Quantity")" value="@(isNew ? "1" : Model.Quantity)">
<div class="input-group-text">
<button type="button" class="btn btn-sm zero-y-padding btn-primary" onclick="replenishSupplies()">+</button>
</div>
</div>
</div>
<div class="col-md-6 col-12">
<label for="supplyRecordCost">@translator.Translate(userLanguage,"Cost")</label>
<input type="text" id="supplyRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost")" value="@(isNew ? "" : Model.Cost)">
<input type="text" inputmode="decimal" id="supplyRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost")" value="@(isNew ? "" : Model.Cost)">
</div>
</div>
<label for="supplyRecordTag">@translator.Translate(userLanguage, "Tags(optional)")</label>
<select multiple class="form-select" id="supplyRecordTag">
@foreach (string tag in Model.Tags)
{
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="supplyRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
@@ -65,6 +85,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.RequisitionHistory.Any())
{
<button type="button" class="btn btn-warning" onclick="toggleSupplyUsageHistory()"><i class="bi bi-clock-history"></i></button>
}
<button type="button" class="btn btn-danger" onclick="deleteSupplyRecord(@Model.Id)" style="margin-right:auto;">@translator.Translate(userLanguage,"Delete")</button>
}
<button type="button" class="btn btn-secondary" onclick="hideAddSupplyRecordModal()">@translator.Translate(userLanguage,"Cancel")</button>
@@ -77,15 +101,16 @@
<button type="button" class="btn btn-primary" onclick="saveSupplyRecordToVehicle(true)">@translator.Translate(userLanguage,"Edit Supply Record")</button>
}
</div>
@await Html.PartialAsync("_SupplyRequisitionHistory", Model.RequisitionHistory)
<script>
var uploadedFiles = [];
getUploadedFilesFromModel();
function getUploadedFilesFromModel() {
@foreach (UploadedFiles filesUploaded in Model.Files)
{
@:uploadedFiles.push({ name: "@filesUploaded.Name", location: "@filesUploaded.Location" });
}
@foreach (UploadedFiles filesUploaded in Model.Files)
{
@:uploadedFiles.push({ name: "@filesUploaded.Name", location: "@filesUploaded.Location" });
}
}
function getSupplyRecordModelData() {
return { id: @Model.Id}
}

View File

@@ -6,13 +6,24 @@
var userLanguage = userConfig.UserLanguage;
var enableCsvImports = userConfig.EnableCsvImports;
var hideZero = userConfig.HideZero;
var recordTags = Model.SelectMany(x => x.Tags).Distinct();
}
@model List<SupplyRecord>
<div class="row">
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center flex-wrap">
<span class="ms-2 badge bg-success">@($"{translator.Translate(userLanguage,"# of Supply Records")}: {Model.Count()}")</span>
<span class="ms-2 badge bg-primary">@($"{translator.Translate(userLanguage,"Total")}: {Model.Sum(x => x.Cost).ToString("C")}")</span>
<span class="ms-2 badge bg-success" data-aggregate-type="count">@($"{translator.Translate(userLanguage, "# of Supply Records")}: {Model.Count()}")</span>
<span class="ms-2 badge bg-primary" data-aggregate-type="sum">@($"{translator.Translate(userLanguage, "Total")}: {Model.Sum(x => x.Cost).ToString("C")}")</span>
@foreach (string recordTag in recordTags)
{
<span onclick="filterTable('supply-tab-pane', this)" class="user-select-none ms-2 rounded-pill badge bg-secondary tagfilter" style="cursor:pointer;">@recordTag</span>
}
<datalist id="tagList">
@foreach (string recordTag in recordTags)
{
<!option value="@recordTag"></!option>
}
</datalist>
</div>
<div>
@if (enableCsvImports)
@@ -59,13 +70,13 @@
<tbody>
@foreach (SupplyRecord supplyRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditSupplyRecordModal(@supplyRecord.Id)">
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@supplyRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditSupplyRecordModal,@supplyRecord.Id)" data-tags='@string.Join(" ", supplyRecord.Tags)'>
<td class="col-2 col-xl-1">@supplyRecord.Date.ToShortDateString()</td>
<td class="col-2">@supplyRecord.PartNumber</td>
<td class="col-2">@supplyRecord.PartSupplier</td>
<td class="col-2 col-xl-3">@supplyRecord.Description</td>
<td class="col-1">@supplyRecord.Quantity</td>
<td class="col-1">@((hideZero && supplyRecord.Cost == default) ? "---" : supplyRecord.Cost.ToString("C"))</td>
<td class="col-1" data-record-type="cost">@((hideZero && supplyRecord.Cost == default) ? "---" : supplyRecord.Cost.ToString("C"))</td>
<td class="col-2 text-truncate">@CarCareTracker.Helper.StaticHelper.TruncateStrings(supplyRecord.Notes)</td>
</tr>
}
@@ -80,4 +91,12 @@
<div class="modal-content" id="supplyRecordModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'SupplyRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'SupplyRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -0,0 +1,72 @@
@using CarCareTracker.Helper
@inject IConfigHelper config
@inject ITranslationHelper translator
@{
var userConfig = config.GetUserConfig(User);
var userLanguage = userConfig.UserLanguage;
}
@model List<SupplyUsageHistory>
<script>
var supplyUsageHistory = [];
</script>
<div id="supplyUsageHistoryModalContainer" class="d-none">
<div class="modal-header">
<h5 class="modal-title">@translator.Translate(userLanguage, "Supply Requisition History")</h5>
</div>
<div class="modal-body">
@if (Model.Any())
{
<div class="row">
<div class="col-12" style="max-height:50vh; overflow-y:auto;">
<table class="table table-hover">
<thead class="sticky-top">
<tr class="d-flex">
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Date")</th>
@if(Model.Any(x=>!string.IsNullOrWhiteSpace(x.PartNumber))){
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Part Number")</th>
<th scope="col" class="col-4">@translator.Translate(userLanguage, "Description")</th>
} else
{
<th scope="col" class="col-6">@translator.Translate(userLanguage, "Description")</th>
}
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Quantity")</th>
<th scope="col" class="col-2">@translator.Translate(userLanguage, "Cost")</th>
</tr>
</thead>
<tbody>
@foreach (SupplyUsageHistory usageHistory in Model)
{
<script>
supplyUsageHistory.push({ date: decodeHTMLEntities("@usageHistory.Date.ToShortDateString()"), partNumber: decodeHTMLEntities('@usageHistory.PartNumber'), description: decodeHTMLEntities("@usageHistory.Description"), quantity: decodeHTMLEntities("@usageHistory.Quantity.ToString("F")"), cost: decodeHTMLEntities("@usageHistory.Cost.ToString("F")") })
</script>
<tr class="d-flex">
<td class="col-2">@StaticHelper.TruncateStrings(usageHistory.Date.ToShortDateString())</td>
@if (!string.IsNullOrWhiteSpace(usageHistory.PartNumber))
{
<td class="col-2 text-truncate">@StaticHelper.TruncateStrings(usageHistory.PartNumber)</td>
<td class="col-4 text-truncate">@StaticHelper.TruncateStrings(usageHistory.Description)</td>
} else
{
<td class="col-6 text-truncate">@StaticHelper.TruncateStrings(usageHistory.Description, 50)</td>
}
<td class="col-2">@usageHistory.Quantity.ToString("F")</td>
<td class="col-2">@usageHistory.Cost.ToString("C2")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="row">
<div class="col-12">
<div class="text-center">
<h4>@translator.Translate(userLanguage, "No supply requisitions in history")</h4>
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -4,6 +4,7 @@
@{
var userConfig = config.GetUserConfig(User);
var userLanguage = userConfig.UserLanguage;
var recordTags = Model.SelectMany(x => x.Tags).Distinct();
}
@model List<SupplyRecord>
<div class="modal-header">
@@ -14,10 +15,16 @@
@if (Model.Any())
{
<div class="row">
<div class="col-12" style="max-height:50vh; overflow-y:auto;">
<div class="col-12" style="max-height:50vh; overflow-y:auto;" id="supplies-table">
<div class="alert alert-warning" role="alert">
@translator.Translate(userLanguage,"Supplies are requisitioned immediately after the record is created and cannot be modified. If you have incorrectly entered the amount you needed you will need to correct it in the Supplies tab.")
</div>
<div class="d-flex align-items-center flex-wrap">
@foreach (string recordTag in recordTags)
{
<span onclick="filterTable('supplies-table', this)" class="user-select-none ms-2 rounded-pill badge bg-secondary tagfilter" style="cursor:pointer;">@recordTag</span>
}
</div>
<table class="table table-hover">
<thead class="sticky-top">
<tr class="d-flex">
@@ -32,9 +39,9 @@
<tbody>
@foreach (SupplyRecord supplyRecord in Model)
{
<tr class="d-flex" id="supplyRows">
<tr class="d-flex" id="supplyRows" data-tags='@string.Join(" ", supplyRecord.Tags)'>
<td class="col-1"><input class="form-check-input" type="checkbox" onchange="toggleQuantityFieldDisabled(this)" value="@supplyRecord.Id"></td>
<td class="col-2"><input type="text" disabled onchange="recalculateTotal()" class="form-control"></td>
<td class="col-2"><input type="text" inputmode="decimal" disabled onchange="recalculateTotal()" class="form-control"></td>
<td class="col-2 supplyquantity">@supplyRecord.Quantity</td>
<td class="col-2 text-truncate">@StaticHelper.TruncateStrings(supplyRecord.PartNumber)</td>
<td class="col-3 text-truncate">@StaticHelper.TruncateStrings(supplyRecord.Description)</td>
@@ -122,7 +129,7 @@
$("#selectSuppliesButton").attr('disabled', (hasError || totalSum == 0));
if (!hasError) {
return {
totalSum: totalSum,
totalSum: globalFloatToString(totalSum),
selectedSupplies: selectedSupplies.toArray()
};
} else {

View File

@@ -24,8 +24,16 @@
</div>
<label for="taxRecordDescription">@translator.Translate(userLanguage,"Description")</label>
<input type="text" id="taxRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage,"Description of tax paid(i.e. Registration)")" value="@Model.Description">
@if (isNew)
{
<div class="row">
<div class="col-12">
<a onclick="showRecurringReminderSelector('taxRecordDescription')" class="btn btn-link">@translator.Translate(userLanguage, "Select Reminder")</a>
</div>
</div>
}
<label for="taxRecordCost">@translator.Translate(userLanguage,"Cost")</label>
<input type="text" id="taxRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of tax paid")" value="@(isNew? "" : Model.Cost)">
<input type="text" inputmode="decimal" id="taxRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of tax paid")" value="@(isNew? "" : Model.Cost)">
<label for="taxRecordTag">@translator.Translate(userLanguage,"Tags(optional)")</label>
<select multiple class="form-select" id="taxRecordTag">
@foreach (string tag in Model.Tags)
@@ -33,6 +41,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="taxRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
@@ -99,6 +115,7 @@
<script>
var uploadedFiles = [];
var customMonthInterval = @Model.CustomMonthInterval;
var recurringReminderRecordId = 0;
getUploadedFilesFromModel();
function getUploadedFilesFromModel() {
@foreach (UploadedFiles filesUploaded in Model.Files)

View File

@@ -67,7 +67,7 @@
<tbody>
@foreach (TaxRecord taxRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditTaxRecordModal(@taxRecord.Id)" data-tags='@string.Join(" ", taxRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@taxRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditTaxRecordModal,@taxRecord.Id)" data-tags='@string.Join(" ", taxRecord.Tags)'>
<td class="col-3 col-xl-1">@taxRecord.Date.ToShortDateString()</td>
<td class="col-4 col-xl-6">@taxRecord.Description</td>
<td class="col-2" data-record-type="cost">@((hideZero && taxRecord.Cost == default) ? "---" : taxRecord.Cost.ToString("C"))</td>
@@ -85,4 +85,12 @@
<div class="modal-content" id="taxRecordModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'TaxRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'TaxRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -23,11 +23,19 @@
<span class="input-group-text"><i class="bi bi-calendar-event"></i></span>
</div>
<label for="upgradeRecordMileage">@translator.Translate(userLanguage,"Odometer")</label>
<input type="number" id="upgradeRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when upgraded/modded")" value="@(isNew ? "" : Model.Mileage)">
<input type="number" inputmode="numeric" id="upgradeRecordMileage" class="form-control" placeholder="@translator.Translate(userLanguage,"Odometer reading when upgraded/modded")" value="@(isNew ? "" : Model.Mileage)">
<label for="upgradeRecordDescription">@translator.Translate(userLanguage,"Description")</label>
<input type="text" id="upgradeRecordDescription" class="form-control" placeholder="@translator.Translate(userLanguage,"Description of item(s) upgraded/modded")" value="@Model.Description">
@if (isNew)
{
<div class="row">
<div class="col-12">
<a onclick="showRecurringReminderSelector('upgradeRecordDescription')" class="btn btn-link">@translator.Translate(userLanguage, "Select Reminder")</a>
</div>
</div>
}
<label for="upgradeRecordCost">@translator.Translate(userLanguage,"Cost")</label>
<input type="text" id="upgradeRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the upgrade/mods")" value="@(isNew ? "" : Model.Cost)">
<input type="text" inputmode="decimal" id="upgradeRecordCost" class="form-control" placeholder="@translator.Translate(userLanguage,"Cost of the upgrade/mods")" value="@(isNew ? "" : Model.Cost)">
@if (isNew)
{
@await Html.PartialAsync("_SupplyStore", "UpgradeRecord")
@@ -39,6 +47,14 @@
<!option value="@tag">@tag</!option>
}
</select>
@foreach (ExtraField field in Model.ExtraFields)
{
var elementId = Guid.NewGuid();
<div class="extra-field">
<label for="@elementId">@field.Name</label>
<input type="text" id="@elementId" class="form-control @(field.IsRequired ? "extra-field-required" : "")" placeholder="@field.Name" value="@field.Value">
</div>
}
</div>
<div class="col-md-6 col-12">
<label for="upgradeRecordNotes">@translator.Translate(userLanguage,"Notes(optional)")<a class="link-underline link-underline-opacity-0" onclick="showLinks(this)"><i class="bi bi-markdown ms-2"></i></a></label>
@@ -75,6 +91,10 @@
<div class="modal-footer">
@if (!isNew)
{
@if (Model.RequisitionHistory.Any())
{
<button type="button" class="btn btn-warning" onclick="toggleSupplyUsageHistory()"><i class="bi bi-shop"></i></button>
}
<div class="btn-group" style="margin-right:auto;">
<button type="button" class="btn btn-md mt-1 mb-1 btn-danger" onclick="deleteUpgradeRecord(@Model.Id)">@translator.Translate(userLanguage,"Delete")</button>
<button type="button" class="btn btn-md btn-danger btn-md mt-1 mb-1 dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
@@ -97,9 +117,11 @@
<button type="button" class="btn btn-primary" onclick="saveUpgradeRecordToVehicle(true)">@translator.Translate(userLanguage,"Edit Upgrade Record")</button>
}
</div>
@await Html.PartialAsync("_SupplyRequisitionHistory", Model.RequisitionHistory)
<script>
var uploadedFiles = [];
var selectedSupplies = [];
var recurringReminderRecordId = 0;
getUploadedFilesFromModel();
function getUploadedFilesFromModel() {
@foreach (UploadedFiles filesUploaded in Model.Files)

View File

@@ -68,7 +68,7 @@
<tbody>
@foreach (UpgradeRecord upgradeRecord in Model)
{
<tr class="d-flex" style="cursor:pointer;" onclick="showEditUpgradeRecordModal(@upgradeRecord.Id)" data-tags='@string.Join(" ", upgradeRecord.Tags)'>
<tr class="d-flex user-select-none" style="cursor:pointer;" ontouchstart="detectRowLongTouch(this)" ontouchend="detectRowTouchEndPremature(this)" data-rowId="@upgradeRecord.Id" oncontextmenu="showTableContextMenu(this)" onmousemove="rangeMouseMove(this)" onclick="handleTableRowClick(this, showEditUpgradeRecordModal,@upgradeRecord.Id)" data-tags='@string.Join(" ", upgradeRecord.Tags)'>
<td class="col-2 col-xl-1">@upgradeRecord.Date.ToShortDateString()</td>
<td class="col-2">@upgradeRecord.Mileage</td>
<td class="col-3 col-xl-4">@upgradeRecord.Description</td>
@@ -86,4 +86,17 @@
<div class="modal-content" id="upgradeRecordModalContent">
</div>
</div>
</div>
</div>
<ul class="table-context-menu dropdown-menu" style="display:none;">
<li><a class="context-menu-multiple context-menu-select-all dropdown-item" href="#" onclick="selectAllRows()">@translator.Translate(userLanguage, "Select All")</a></li>
<li><a class="context-menu-multiple context-menu-deselect-all dropdown-item" href="#" onclick="clearSelectedRows()">@translator.Translate(userLanguage, "Deselect All")</a></li>
<li><a class="context-menu-active-multiple dropdown-item" href="#" onclick="editMultipleRecords(selectedRow, 'UpgradeRecord')">@translator.Translate(userLanguage, "Edit Multiple")</a></li>
<li><hr class="context-menu-multiple dropdown-divider"></li>
<li><h6 class="dropdown-header">@translator.Translate(userLanguage, "Move To")</h6></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'UpgradeRecord', 'ServiceRecord')">@translator.Translate(userLanguage, "Service Records")</a></li>
<li><a class="dropdown-item" href="#" onclick="moveRecords(selectedRow, 'UpgradeRecord', 'RepairRecord')">@translator.Translate(userLanguage, "Repairs")</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="duplicateRecords(selectedRow, 'UpgradeRecord')">@translator.Translate(userLanguage, "Duplicate")</a></li>
<li><a class="dropdown-item" href="#" onclick="deleteRecords(selectedRow, 'UpgradeRecord')">@translator.Translate(userLanguage, "Delete")</a></li>
</ul>

View File

@@ -27,7 +27,7 @@
<form class="form-inline">
<div class="form-group">
<label for="inputYear">@translator.Translate(userLanguage, "Year")</label>
<input type="number" id="inputYear" class="form-control" placeholder="@translator.Translate(userLanguage, "Year(must be after 1900)")" value="@(isNew ? "" : Model.Year)">
<input type="number" inputmode="numeric" id="inputYear" class="form-control" placeholder="@translator.Translate(userLanguage, "Year(must be after 1900)")" value="@(isNew ? "" : Model.Year)">
<label for="inputMake">@translator.Translate(userLanguage, "Make")</label>
<input type="text" id="inputMake" class="form-control" placeholder="@translator.Translate(userLanguage, "Make")" value="@Model.Make">
<label for="inputModel">@translator.Translate(userLanguage, "Model")</label>

14
docs/documentation/API.md Normal file
View File

@@ -0,0 +1,14 @@
# API
LubeLogger provides API endpoints to retrieve and add records, full documentation of these endpoints can be found at `/api`.
## Authentication
If authentication is enabled, it implements Basic Auth based on RFC2617, which stipulates that the "token" is passed in as a Base64-encoded string comprising of a username and password separated by a colon(":"). Because of this, neither the username nor password can contain a colon(":") character.
### Testing
You can utilize any REST API testing tool to test your use-case.
## Example Use Cases
- Send Email Reminders, see [[Reminders|Records/Reminders#reminder-emails]]
- Insert Odometer Records, see [[Odometer|Records/Odometer#api-integration]]
- Create DB Backups

View File

@@ -0,0 +1,45 @@
# Authentication
LubeLogger does not require authentication by default; however, it is highly recommend that you set up authentication if your LubeLogger instance is accessible vvia the Internet or if you wish to invite other users to your instance.
## Enabling Authentication
To enable authentication, all you have to do is navigate to the "Settings" tab and check "Enable Authentication".
A dialog will then prompt you to enter a username and password. These are the credentials for the Root/Super User.
Once you have entered the credentials, click the "Setup" button and you will be redirected to a login screen, enter the credentials of the Root/Super User here to login.
## Creating / Inviting New Users
LubeLogger relies on an invitation-only model for creating/registering new users. It is highly recommended that LubeLogger is configured with SMTP in order to make the user registration process as smooth as possible, see [[Getting Started]] for more information regarding SMTP configuration.
To Create/Invite New Users, you first need to enable authentication and set up the Root User credentials. Once that is done, upon login, you will see that there is now a dropdown to the right of the "Settings" tab that has your root username on it. Click on that dropdown and select "Admin Panel"
![](/Authentication/a/image-1706398957700.png)
You will now be taken to a new page. There are two sections in this page, Tokens and Users.
![](/Authentication/a/image-1706398972637.png)
Tokens are used for invitees to register their user account or existing users to reset their password. These tokens are single use and are validated against the email address they are issued for.
Users, as the name suggests, is a list of users in the system. You can also mark or unmark existing users as Admins here, see below to understand what permissions Admin users have.
To invite a user, simply click on the "Generate User Token" button and type in their email address, note that this is case-sensitive.
![](/Authentication/a/image-1706398926360.png)
If SMTP is configured and the Auto Notify(via Email) switch is checked, the user will receive an email that looks like this:
![](/Authentication/a/image-1706398865186.png)
## Root/Super User
You might be tempted to use your root credentials as your main credentials, and there is nothing wrong that, but you should know that there are a few caveats associated with the root user.
1. Root/Super Users can view and edit all vehicles, this might seem like a great advantage initially, but it can be problematic if there are sufficient users and you have to sift through dozens of vehicles to get to yours.
2. Any setting you enable/disable will become the default setting inherited by new users.
For the reasons above, it is highly recommended that you create a second user for personal use and mark it as an Admin. See below for a breakdown of permissions across user tiers.
| Permissions | User | Admin | Root/Super User |
| ---------------------- | ----------------------------------------- | ----------------------------------------- | ---------------------- |
| View/Edit Vehicles | Only vehicles which they are collaborator | Only vehicles which they are collaborator | View/Edit All Vehicles |
| Access API | Yes | Yes | Yes |
| Personalized Settings | Yes | Yes | No, settings will be set as server default |
| Add/Remove Users | No | Yes | Yes |
| Make/Restore Backups | No | No | Yes |
| Disable Authentication | No | No | Yes |

View File

@@ -1,21 +0,0 @@
# Sharing your Vehicles and Collaborating with Other Users
LubeLogger allows you to collaborate on vehicles so that more than one user can add or edit records on a vehicle.
To share a vehicle, simply navigate into the Vehicle's Dashboard, and look to the bottom left
![image](https://github.com/hargata/lubelog/assets/155338622/76eda35f-5547-47d9-91cb-3fbf30d2a3d9)
Click on the little blue button with a User Add icon and you will be prompted to enter the user's username
![image](https://github.com/hargata/lubelog/assets/155338622/6bbcc309-a790-48e0-91fa-24c57df20531)
**Note:** The username is case sensitive and the user must exist in the system.
Once you have added the user, their username will then show up in the list of Collaborators
![image](https://github.com/hargata/lubelog/assets/155338622/edac8c3b-eb3e-411d-ab45-e54cb66206f3)
Now the user can view and edit the vehicle as well:
![image](https://github.com/hargata/lubelog/assets/155338622/85b0aacf-ae5c-4361-8d1c-32f9ec637a5b)

View File

@@ -1,51 +0,0 @@
# Configuring LubeLogger
In order to provide the best possible user experience, we have provided ample amount of flexibility when it comes to user settings.
Upon initial launch, you are using the Root User by default without any authentication, so you will have access to all of the settings.
![image](https://github.com/hargata/lubelog/assets/155338622/cf73ff68-e79b-468d-84fb-375e481cbadd)
Most of the settings are relatively straightforward and self-explanatory.
**Note:** If you are a user in the UK and you wish be able to input Fuel Purchases in Liters but display Fuel Mileage as Miles Per UK Gallons, you will need to enable "Use Imperial Calculation" and "Use UK MPG Calculation"
**Note:** When making changes to Settings as a root user, your settings will be saved and served up as the default server settings for any new users that sign up.
## Enable Authentication
It is highly recommended that you secure your LubeLogger instance by enabling authentication.
To do so, simply check "Enable Authentication" and you will be prompted to enter a Username and Password
![image](https://github.com/hargata/lubelog/assets/155338622/4d8b1855-e437-4ade-999b-3dd6ea19e55f)
The credentials that you set up here are the credentials for the Root User, aka the Super Admin, and shouldn't be shared with anyone else.
Once you have entered the credentials, you will then be redirected to a Login page
![image](https://github.com/hargata/lubelog/assets/155338622/26181116-5a03-48cf-972d-89a4b1050cce)
Simply enter the credentials you have just set up and you will be logged right in
![image](https://github.com/hargata/lubelog/assets/155338622/20d9a3b9-80de-4a32-8cb6-1100dd237dbd)
## Setting Up Multiple Users
To set up multiple users, all you have to do is click on the dropdown that has your username on it and select "Admin Panel"
![image](https://github.com/hargata/lubelog/assets/155338622/75f32408-b9f3-4e2c-bec6-ea1c196c3438)
If you have SMTP configured correctly, the "Auto Notify(via Email) switch will be enabled and checked, otherwise it will be disabled/grayed out.
Without SMTP Configured:
![image](https://github.com/hargata/lubelog/assets/155338622/9d75ab55-afb7-4d3e-b830-3191de23695c)
With SMTP Configured:
![image](https://github.com/hargata/lubelog/assets/155338622/d9995c30-dd47-44d1-a73d-39f0f129450b)
To generate a new user token, simply click on the "Generate User Token" button and you will be prompted with the user's email address
![image](https://github.com/hargata/lubelog/assets/155338622/f870d5cd-4d4b-480c-94ea-b1161757793f)
If you have SMTP Configured, the user will then receive an Email that looks similar to this:
![image](https://github.com/hargata/lubelog/assets/155338622/0d5c1adf-43d1-45a5-ad59-91fb3b1476af)
The user can then proceed to Register for an account at your instance of LubeLogger.

View File

@@ -0,0 +1,32 @@
# Dashboard
The Dashboard is where you get an overview of your vehicle. It is the default tab that the user will see when they click into a vehicle and it is also the only tab that cannot be hidden.
![](/Dashboard/a/image-1706633228249.png)
The Dashboard includes the following data:
- Expenses By Type By Year/All Time(Top-Left)
- Total Expenses By Month By Year/All Time(Top-Center)
- Reminders by current date or future date(Top-Right)
- Collaborators(Bottom-Left), see [[Adding Collaborators|Vehicle Management#adding-a-collaborator]]
- Fuel Mileage By Month By Year/All Time(Bottom-Center)
- Vehicle Maintenance Report Generator(Bottom-Right)
## Filtering Data by Year
The year dropdown above the pie chart(top-left) allows the user to filter the aggregated data by year. The year selections are populated by retrieving all years between the year of the oldest record and the current year. If the oldest record is less than 5 years old, the selections will still be populated by the last 5 years.
Note: Changing the selected year will automatically refresh the Expenses by Type Pie Chart, the Total Expenses by Month and the Fuel Mileage By Month Bar Charts.
![](/Dashboard/a/image-1706477893556.png)
## Vehicle Maintenance Report
The Vehicle Maintainence Report Generator is a button that will generate a consolidated report of all work performed on the vehicle. For performance reasons, this report is designed to be printed as soon as it is generated. You can either choose to print it to paper or PDF.
![](/Dashboard/a/image-1706478028103.png)
## Export Attachments
The Export Attachments button provies a convenient feature to export all attachments into a chronologically-ordered zip file. Upon clicking the button, you will be prompted to select which tabs you want attachments to be exported from.
![](/Dashboard/a/image-1706574853389.png)
Once you have made your selection, a zip file will be created and downloaded onto your computer. This zip file contains all of the attachments and are named in chronological order(i.e.: the oldest attachment will be named 0 and the second oldest attachment 1, 2...)

View File

@@ -0,0 +1,62 @@
# Fuel Records
The Fuel tab keeps track of the fuel mileage for your vehicle.
![](/Records/Fuel%20Records/a/image-1707455181666.png)
LubeLogger supports fuel mileage calculation in the following formats:
- American Imperial (MPG)
- European/Asian Metric (L/100Km)
- British(Purchase Gas in Liters and calculate fuel mileage as Miles per Imperial UK Gallons)
- Electric Vehicles (mi./kWh or kWh/100Km)
## Initial Fuel Up
In order to calculate fuel mileage, you must first have an initial fuel entry with the current odometer reading. An odometer reading is needed so that the app can calculate the distance traveled between fuel ups. It is recommended that you fill it up to full for the first entry.
## Imperfect Fuel Ups
For the most accurate results it is recommended that you always fill your vehicle up to full and not miss any fuel ups, but sometimes things happen, which is why we provided the following:
### Partial Fuel Ups
On the occassions that you cannot fill your vehicle up to full, you can defer the fuel mileage calculation by unchecking the "Is Filled To Full" switch. Doing this tells the app to defer fuel mileage calculation until the next Full Fill Up.
![](/Records/Fuel%20Records/a/image-1706406318412.png)
### Missed Fuel Ups
Check this if you have missed a fuel up record prior to adding this fuel record. This effectively resets the fuel mileage calculation and will show up as $0 or "---" in the fuel records. Checking this ensures that the average fuel mileage calculation isn't skewed due to missed fuel ups.
## Calculation of Average Fuel Mileage
Average MPG is calculated by excluding the initial and missed fuel ups, then taking the difference between the min and max odometer reading and dividing it by total amount of gas consumed(if Metric then it is further divided by 1/100). This method will include all of the gas consumed by full and partial fuel ups.
## Fuel Units
The consumption, fuel mileage, and odometer units are determined by two settings: "Use Imperial Calculation" and "Use UK MPG Calculation"
| Setting | Use UK MPG Calculation Checked | Use UK MPG Calculation Unchecked |
| -------- | -------- | -------- |
| Use Imperial Calculation Checked | Distance: Miles, Consumption: Liters, Fuel Mileage: Miles per UK Gallons | Distance: Miles, Consumption: Gallons, Fuel Mileage: MPG |
| Use Imperial Calculation Unchecked | Distance: Miles, Consumption: Liters, Fuel Mileage: l/100mi. | Distance: Km, Consumption: Liters, Fuel Mileage: l/100km |
### Alternate Fuel Units
If you wish to see alternate units which converts the calculated units within the Gas Tab, you can right click on the table headers for Consumption and Fuel Economy. These settings persists for the user, so the next time you login to LubeLogger it will automatically perform the conversion for you.
![](/Records/Fuel%20Records/a/image-1706797201107.gif)
#### Consumption
For consumption, the units are cycled between US gal, Liters, and Imp Gal(UK). Changing the consumption unit will also change the unit cost.
#### Fuel Economy
The units can only toggle between l/100km and km/l, which means that this unit cannot be converted if your fuel economy unit is not l/100km. Changing the fuel economy unit will also update the values in the Average, Min, and Max Fuel Economy labels as well as the Fuel Economy Unit in the Consolidated Report.
## Importing from CSV/Fuelly/SpiritMonitor.de
LubeLogger supports importing CSV exports from other apps, below lists the column names that are acceptable/mapped to our data points:
| LubeLogger Data Field | Imported CSV |
| -------------------------------------- | -------------------------------------------------------------- |
| date | date, fuelup_date |
| odometer | odometer |
| fuelconsumed | gallons, liters, litres, consumption, quantity, fuelconsumed |
| cost | cost, total cost, totalcost, total price |
| notes | notes, note |
| partialfuelup(inverse of isfilltofull) | partial_fuelup |
| isfilltofull | isfilltofull, filled up |
| missedfuelup | missedfuelup, missed_fuelup |
| tags | tags |

View File

@@ -27,7 +27,7 @@ By default the app will start listening at localhost:8080, this port can be conf
## Windows Standalone Executable
Windows Standalone executables are provided on a request basis, and will usually be included with every other release.
To run the server, you just have to double click on CarCareTracker.exe
To run the server, you just have to download the zip archive attached to the release, usually named LubeLogger_vNNN_win_x64.zip, extract the archive and double click on CarCareTracker.exe
Occassionally you might run into an issue regarding a missing folder, to fix that, just create a "config" folder where CarCareTracker.exe is located.
@@ -71,5 +71,3 @@ When using this approach, the default port the app will be listening on is 5000,
## Test that It Works
Whichever path you choose, once you get the app up and running, just navigate to the IP address and port the server is listening to and you should be able to see the app
![image](https://github.com/hargata/lubelog/assets/155338622/a32c40ce-271c-406b-a211-c1f2af138418)
![image](https://github.com/hargata/lubelog/assets/155338622/4e1c913c-001e-43fd-9b56-4ecffdfd4c2e)

View File

@@ -0,0 +1,46 @@
# Set Up HTTPS
LubeLogger runs on Kestrel, which is a cross-platform standalone web server provided by .NET
If you're running LubeLogger behind a reverse proxy(i.e. NGINX), then this walkthrough does not apply to you since the SSL certs will be served up by NGINX instead of Kestrel.
This article covers the step-by-step process to set up HTTPS for a LubeLogger instance.
## Docker
If you're running LubeLogger on a Docker instance, first read [this article by Microsoft](https://learn.microsoft.com/en-us/aspnet/core/security/docker-compose-https?view=aspnetcore-8.0)
1. Convert the .PEM / .CRT files into .PFX, read [this StackOverflow post](https://stackoverflow.com/questions/808669/convert-a-cert-pem-certificate-to-a-pfx-certificate)
2. Open and modify the .env file and add the following lines(note that in this example I used bob as the password for the cert)
```
ASPNETCORE_Kestrel__Certificates__Default__Password=bob
ASPNETCORE_Kestrel__Certificates__Default__Path=/https/<yourPFXCertificateName>.pfx
ASPNETCORE_URLS=https://+:443;http://+:80
```
3. Open and modify docker-compose.yml. You will need to bind a new volume to the Docker container so that Kestrel can access the certificate file.
```
volumes:
- ~/https/:/https:ro
```
4. Run `docker-compose up -d` to start up the container and `https://localhost` will now have a valid cert.
## Windows
If you're running LubeLogger as the standalone Windows executable, first read [this article by Microsoft](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-8.0#configure-https-in-appsettingsjson)
1. Convert the .PEM / .CRT files into .PFX, read [this StackOverflow post](https://stackoverflow.com/questions/808669/convert-a-cert-pem-certificate-to-a-pfx-certificate)
2. Open and modify appsettings.json located in the same directory as the CarCareTracker executable and add the following lines(note that in this example I used bob as the password for the cert)
```
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:80"
},
"HttpsInlineCertFile": {
"Url": "https://localhost:443",
"Certificate": {
"Path": "<path to .pfx file>",
"Password": "bob"
}
}
}
```
3. Restart the app and `https://localhost` will now have a valid cert.

View File

@@ -0,0 +1,25 @@
# Notes
The Notes tab contains important notes about your vehicle.
## Markdown Parsing
Markdown formatting is supported across all Notes fields in the app. The underlying markdown parser is [Drawdown](https://github.com/adamvleggett/drawdown). To toggle between edit and preview mode, simply click on the markdown icon next to the "Notes" label.
![](/Records/Notes/a/image-1706634016273.png)
![](/Records/Notes/a/image-1706634022811.png)
There is also a setting, that when enabled, will automatically load all notes in Markdown form when viewing existing records. The only exception is when an existing record has an empty notes field.
## Pinned Notes
Notes can be pinned by checking the "Pinned" switch when creating or editing a note.
![](/Records/Notes/a/image-1706633376978.png)
Pinned Notes will always show up at the very top of the list.
![](/Records/Notes/a/image-1706455413890.png)
On non-touchscreen devices, pinned notes can be viewed when the user hovers over the vehicle tile in the garage. On mobile, the user have to hold down on the garage tile in order for the pinned notes to show up.
![](/Records/Notes/a/image-1706455460700.png)

View File

@@ -0,0 +1,14 @@
# Odometer
The Odometer tab is where you can log your current odometer reading without having to insert any Service/Repair/Upgrade/Fuel records. This odometer readings entered in this tab allows Reminder urgencies to be calculated as accurately as possible since it uses the maximum mileage reported in each of the tabs to determine the last reported mileage.
The Odometer tab is hidden by default and must be enabled by checking the "Odometer" switch under "Visible Tabs" in the Settings tab.
## API Integration
As with the other tabs, odometer readings can be retrieved via a GET endpoint and inserted via a POST API endpoint.
Example use cases:
- An app to integrate with OBDII and insert odometer reading from the vehicle's computer onto LubeLogger.
- An app to keep track of distance traveled via GPS and incrementing the last reported odometer reading.
These are not functionalities provided out of the box by LubeLogger, and are just examples of the possibilities achievable via the API endpoints.

View File

@@ -0,0 +1,62 @@
# Planner
The Planner tab is where you can plan and track progress of future or current plans for your vehicle. The planner tab consists of 4 swimlanes(vertical panels) where plans can be moved to different stages via drag and drop.
![](/Records/Planner/a/image-1706459391188.png)
The records stored in Service/Repair/Upgrade tabs tend to be inserted retroactively(after the work is done), hence the Planner tab was developed to keep track of To-Do's.
## Adding a new Plan
The Planner tab is hidden by default, you must enable it by checking the "Planner" switch under "Visible Tabs" within the Settings tab.
To add a new plan, simply click on the "Add Plan Record" button and fill in the details of the plan.
![](/Records/Planner/a/image-1707453045755.png)
## Plan Type
You are required to select at least one Plan Type - Service, Repair, or Upgrade. When the Plan Record is moved to Done, the Plan Record will be automatically converted into the selected type and it will then show up in its respective tab. The type is indicated by an icon corresponding to their respective tabs.
## Plan Priority
Within the swimlanes, plans are sorted from Critical priority to Low priority. They are also indicated by an icon - Fire for Critical, Waves for Normal, and Snowflake for Low.
![](/Records/Planner/a/image-1706459640917.png)
## Plan Stages
**Planned** - This is considered the backlog, where no work has been performed yet. i.e.: Shopping for a lift kit.
**Doing** - This is when work has been started on the task. i.e.: Installing the lift kit.
**Testing** - This is where the work is being reviewed / tested. i.e.: Going for a test drive with the lift kit.
**Done** - This is where the work is marked as completed.
### Updating Plan Stages
There are two ways you can update the stage a plan is currently in: drag and drop or updating it via the dropdown when editing a plan. You can backtrack a plan's stages within Planned, Doing, and Testing. i.e.: You can move the plan back from Testing to Doing if you find out there is more work to be done.
### Moving to Done
when a Plan is being moved to Done, it will prompt you to enter the current odometer reading. This is to ensure that the Service/Repair/Upgrade record that will be created from it contains the most up-to-date odometer reading.
Once a Plan has been marked as Done, it can never be taken out of it. The only action that can be performed on it is Delete. You can delete Plans in the Done stage by clicking on them.
## Plan Templates
For records that have a fixed interval(i.e. oil changes), it is recommended that you create a Plan Template instead.
### Creating a Template
To create a template, simply fill in the details / select the supplies to requisition for the plan, but instead of clicking the "Add New Plan Record" button, you will click on the dropdown next to it and select "Save As Template"
![](/Records/Planner/a/image-1707453250314.png)
The template will now be created and named after the description of the plan record.
### Using Templates
To create a plan record from a template, click on the "View Templates button" and a dialog will show up with the saved templates for the vehicle
![](/Records/Planner/a/image-1707453421824.png)
![](/Records/Planner/a/image-1707453431731.png)
The shop icon indicates if the template has supplies requisition and the paper clip icon(not shown) indicates if the template has file attachments.
To use the template, simply click on the "+" button. The app will then perform a check to ensure that there are sufficient quantities of the supplies used by the template. If there are insufficient quantities, an error message will pop up telling you which supply is short.
![](/Records/Planner/a/image-1707453639902.png)

View File

@@ -0,0 +1,53 @@
# Postgres
For users that desire additional scalability for their backend, LubeLogger now supports a PostgreSQL backend.
## Configuration
To configure LubeLogger to use PostgreSQL, you must first create a database with a schema named "app" in it, in the screenshot below we created a DB named "lubelogger" and then a schema named "app".
![](/Postgres/a/image-1707454502212.png)
Once that is done, simply inject the environment variable `POSTGRES_CONNECTION` with your connection string, example:
```
Host=<yourserveraddress:port>;Username=<yourusername>;Password=<yourpassword>;Database=<databasename>;
```
LubeLogger will then automatically create the tables it needs, all records will then be saved and loaded from Postgres tables from now on.
## Backups
Once you have switched over to Postgres, LubeLogger's built in and backup function will only back up images, documents, and the server config. You are responsible for maintaining backups of the DB records.
## Database Migration
A tool is provided to ease the migration process between LiteDB and Postgres. This tool can be found at the `/migration` endpoint and is only accessible when a Postgres connection is provided.
![](/Postgres/a/image-1707516092170.png)
### Importing to Postgres
To transfer all your existing data from LiteDB to Postgres:
1. Create a backup using the "Make Backup" feature in the Settings tab.
2. Extract the zip file.
3. You should see a folder named "data" in the extracted folder
4. Inside the data folder will be a .db file named `cartracker.db`
5. Navigate to the Database Migration tool
6. Click "Import to Postgres"
7. Select the `cartracker.db` file
8. Your data will be imported into your Postgres DB, and you may double check that the DB has been imported successfully using a Postgres DB Administration Tool.
### Exporting from Postgres
In the event that you need to transfer all your data back onto a LiteDB database file from Postgres, you may do so using the Database Migration tool:
1. Navigate to the Database Migration Tool
2. Click "Export from Postgres"
3. Extract the downloaded zip file and you should find `cartracker.db` in it.
4. Create a backup using the "Make Backup" feature in the Settings tab.
5. Extract the zip file.
6. You should see a folder named "data" in the extracted folder, if not, create it.
7. Place `cartracker.db` inside the "data" folder
8. Re-zip the extracted folder
9. Restore the backup using the "Restore Backup" feature in the Settings tab.
10. Make sure you remove the PostgreSQL connection from the environment variables so that all future changes will be saved in LiteDB.

View File

@@ -1,22 +0,0 @@
# Registration
Once they user have receive an email containing their LubeLogger token(pictured below), they can then register for an account.
![image](https://github.com/hargata/lubelog/assets/155338622/80ef7208-bfaa-4cab-ba0e-c0a3ed6f255e)
They can do so by navigating to your LubeLogger instance and clicking on the "Register" link.
![image](https://github.com/hargata/lubelog/assets/155338622/99c1538d-5cfa-4ac8-8946-ef9eb7dfef9a)
Then they just have to provide the token and their email address along with their set of credentials
![image](https://github.com/hargata/lubelog/assets/155338622/c55d7d2d-1366-4fcf-970f-513e92a93c7a)
**Note:** The email address and token are CASE SENSITIVE and MUST be identical to the email address that the token is generated for.
![image](https://github.com/hargata/lubelog/assets/155338622/b77d49ef-d3e3-43bf-9792-360f305ad379)
Once the user has registered successfully, they can then log in using their credentials.
You can also verify that in your panel the token is deleted and the user now shows up under list of users:
![image](https://github.com/hargata/lubelog/assets/155338622/2f4a622a-cb44-4ba9-8b5d-691f677fee3d)

View File

@@ -0,0 +1,49 @@
# Reminders
Reminders are future tasks where the urgency are based on how close the user is to the due date or odometer reading.
![](/Records/Reminders/a/image-1706403372050.png)
## Adding Reminders
Reminders can either be added directly on the Reminders tab by clicking the "Add Reminder" button or via adding a new [[Service/Repair/Upgrade Record|Records/Service Records#adding-reminders]].
## Reminder Metrics
Similar to the maintenance schedule outlined in your vehicle's user manual, the urgency of a Reminder can be set either via a due date, a future odometer reading, or whichever comes first.
![](/Records/Reminders/a/image-1706403594197.png)
## Reminder Urgency
Depending on the metric selected, reminder urgency is calculated either via the server's current date, the max odometer reading across the Odometer/Service/Repair/Upgrade/Fuel tabs, or whichever comes first.
| Urgency | Due Date | Future Odometer Reading |
| ---------- | ------------- | ----------------------- |
| Not Urgent | > 30 days out | > 100 miles out |
| Urgent | < 30 days out | < 100 miles out |
| Very Urgent | < 7 days out | < 50 miles out |
| Past Due | > 0 days past | > 0 miles past |
## Recurring Reminders
Reminders can be set to become recurring so that you don't have to create a new reminder for recurring maintenance such as oil changes. When you have completed the task set by the reminder, you can either have it automatically refresh when it lapses or by manually refreshing it. Refreshing a reminder effectively pushes out the due date or the odometer reading based on the recurring interval, i.e.: if a Reminder is due at 10000 miles and the interval is set at every 5000 miles, refreshing the Reminder will push the future odometer reading out to 15000 miles.
### Automatically Refresh Past Due Reminders
There is a setting within the Settings tab that allows users to automatically refresh past due reminders. Note that with this setting enabled, any reminder that becomes Past Due will be automatically refreshed, this requires a lot of diligence from the user to heed their reminders and stay on top of it.
![](/Records/Reminders/a/image-1706404019404.png)
### Manually Refresh Reminders
When a recurring reminder falls into Very Urgent or Past Due status, there will be a button on the Reminders page that will allow the user to manually refresh the reminder.
![](/Records/Reminders/a/image-1706404336137.png)
This reminder is set to be recurring every 1 year, so when the "Done" button is clicked, it will push the due date of this reminder to 1/31/2025.
![](/Records/Reminders/a/image-1706404394320.png)
![](/Reminders/a/image-1706404403748.png)
## Reminder Emails
If SMTP is configured within LubeLogger, the Root User can set up a cron / scheduled task that runs at an interval to send out emails to collaborators of vehicles with reminders. The API endpoint allows the user to specify what level of urgencies should the user be notified of.
Sample bash script: https://github.com/hargata/lubelog_scripts/blob/main/bash/sendreminders.sh
The sample provided above will send email reminders out for reminders of all urgencies.

View File

@@ -0,0 +1,19 @@
# Replacing The LubeLogger Logo
You can overwrite the LubeLogger Logo that is displayed in the Login and Home/Garage page.
To do so, simply inject an environment variable with the key `LUBELOGGER_LOGO_URL` into your lubelogger instance either via the .env file or the appsettings.json file.
## .env
```
LUBELOGGER_LOGO_URL=<URL to your Logo>
```
## appsettings.json
```
LUBELOGGER_LOGO_URL:<URL to your Logo>
```
## Non-replaceable Locations
- Logo in the About section in the Settings tab
- Logo that shows up in the top left of the Vehicle Maintenance History Report

View File

@@ -0,0 +1,36 @@
# Service/Repair/Upgrade Records
These three are perhaps the most important tabs in LubeLogger. They are functionally identically to one another, except for the type of records stored in them.
Service Records: These are planned/scheduled maintenance performed on the vehicle, usually on a fixed interval. Examples include oil changes, brakes, tires, spark plugs, air filter.
Repair Records: These are unplanned/unscheduled work performed on the vehicle whether due to an accident, a component breaking unexpectedly, or a broken component with no fixed maintenance interval. Examples include replacing the alternators, starters, radiators, power steering pump, bumpers.
Upgrade Records: These are work performed on the vehicle that enhances the functionality or aesthetics of the vehicle. Examples include: roof racks, lift kits, aftermarket wheels, stereos.
To add a new record, simply navigate to the tab and click the "Add New Service/Repair/Upgrade Record" button and you will be prompted to input the details of the record.
![](/Records/Service%20Records/a/image-1706797383329.png)
## Moving Records
To move existing records between the three tabs, simply click on the dropdown button to the right of the Delete button and select the tab to move the record to.
![](/Records/Service%20Records/a/image-1706797399768.png)
## Supplies Requisition
If you have supplies set up, you can click the "Choose Supplies" link under the Cost field, and a dialog will prompt you to select the supplies and quantity of each supplies you wish to requisition for this record.
![](/Records/Service%20Records/a/image-1706402198461.png)
Once you have selected the supplies, the Cost field will automatically update to reflect the costs of the supplies you have selected based on the quantity of each supply. Note that at this point, before the record is created, the supply is not requisitioned yet and you can still edit the selected supplies/quantities.
Once the record has been created, the supplies will be requisitioned and the quantity / cost of the supplies will be deducted according to the usage. This cannot be reversed(i.e.: you cannot restore the quantities by editing an existing service/repair/upgrade record), you have to go to the Supplies tab to correct the quantity/cost of the supply.
### Supplies Unit Cost Calculation
LubeLogger is not an inventory management system. Unit costs are calculated as an average of total spent / quantity, which means that everytime you replenish your supplies, it will average out the cost even if the latest batch of supplies you purchased is significantly costlier than the last batch. There is no LIFO/FIFO/FAFO inventory valuation methods.
For more information on Supplies, see [[Supplies|Records/Supplies]]
## Adding Reminders
You are given the option to set a reminder upon creating a record. This is helpful for recurring services such as Oil Changes. To do so, simply check the "Add Reminder" switch before clicking the "Add New Service Record" button. A new dialog will show up after the record has been created and all the fields will be pre-populated.
For more information on Reminders, see [[Reminders|Records/Reminders]]

View File

@@ -0,0 +1,27 @@
# Supplies
The Supplies tab is where you can keep track of supplies or parts purchased for your vehicle, either as spare parts or for future use.
![](/Records/Supplies/a/image-1706402930133.png)
The Supplies tab is hidden by default and requires the user to enable it via the Settings tab under the "Visible Tabs" section.
![](/Records/Supplies/a/image-1706403044005.png)
To add a new Supply Record, simply click the "Add New Supply Record" button and you will be prompted for the details of the Supply.
![](/Records/Supplies/a/image-1706403006743.png)
Supplies that are in the system that have a quantity greater than zero are available for [[Requisitioning|Records/Service Records#supplies-requisition]]
## Shop Supplies
Shop supplies are for supplies that are at a garage level and is available for requisitioning across all vehicles. This is useful for supplies that are shared across multiple vehicles such as motor oil, washer fluid, tires, etc.
![](/Records/Supplies/a/image-1707454148363.png)
This tab is disabled by default, but can be enabled by the Root User in the Settings tab.
![](/Records/Supplies/a/image-1707454169597.png)
Note that shop supplies are available for all users and all vehicles, do not enable this if you don't wish to share supplies with other users. When enabled, any user can add / edit / delete any shop supplies.

View File

@@ -0,0 +1,22 @@
# Taxes
The Taxes tab keeps track of fees and expenses that are incurred by your vehicle that are unrelated to odometer readings.
![](/Records/Taxes/a/image-1706496894832.png)
Examples of records that can be recorded in the Taxes tab include:
- Registration Fee/Road Tax
- Insurance
- Fines/Citations
- Roadside Assistance
- Car Washes
- and more!
## Recurring Taxes
Taxes can be set to be recurring so that a new record is created at every interval. The newly-created tax record is duplicated from the last recurrence.
![](/Records/Taxes/a/image-1706496915044.png)
The way this functionality works is that when the user clicks into a vehicle detail, a check is performed if there are any recurring tax records that are past due. If there are, those tax records are then duplicated with the due date pushed out by the set interval. The newly duplicated will be set to recur at the set interval while the tax record that was past due will have recurrance disabled.
### Notes on Recurring Taxes
Since recurring taxes are duplicated from the previous occurrence, it is not recommended for fees with differing values be set up as recurring, i.e.: Registration Fees do not stay constant year to year in most if not all parts of the United States, even if most people renew their vehicle's registration at the same time every year.

View File

@@ -0,0 +1,35 @@
# Translations
LubeLogger supports UI Translations for ~95% of UI elements.
The following are not covered by translations:
- Toasts(messages that pop up on the top right)
- Sweetalert prompts(confirm delete dialogs, etc)
- About section
## Where to get translations
Translations can be found at [this repository](https://github.com/hargata/lubelog_translations/)
1. To upload a translation file, login as the root user.
2. Navigate to "Settings"
3. Click "Upload" under the "Manage Languages" section
![](/Translations/a/image-1707068703210.png)
4. Select the language file you wish to upload
5. The page should refresh
6. Select the language file from the dropdown to set it as your default language.
## Creating your own translation
1. Download the [latest en_US.json](https://github.com/hargata/lubelog/blob/main/wwwroot/defaults/en_US.json) file from the GitHub Repository for LubeLogger.
2. Rename this file, en_US is a reserved name.
3. Use a JSON pretty-printer to make it human-readable
![](/Translations/a/image-1707068227706.png)
3. The objects to the left of the ":" are the translation keys, DO NOT modify these.
4. The objects to the right of the ":" are the translation values(shown in green), these are what you want to translate.
5. To test out your translation, simply upload it to your LubeLogger instance and test it out.
## Contribute
Follow the instructions outlined in the [official repository](https://github.com/hargata/lubelog_translations/)
Translation efforts are coordinated via [this thread](https://github.com/hargata/lubelog/discussions/240)

Some files were not shown because too many files have changed in this diff Show More