Implement code review fixes and improvements
- Move admin credentials from hardcoded values to configuration - Add rate limiting (5/min) to login endpoint for brute force protection - Extract CleanJsonResponse to shared JsonResponseHelper class - Add DateHelpers.MonthsBetween utility and consolidate date calculations - Update PdfReportService to use ScoreThresholds constants - Remove 5 unused shared components (EmploymentTable, FlagsList, etc.) - Clean up unused CSS from MainLayout.razor.css - Create IPdfReportService interface for better testability - Add authentication requirement to Hangfire dashboard in development - Seal EducationVerifierService class Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,22 @@ namespace TrueCV.Application.Helpers;
|
|||||||
|
|
||||||
public static class DateHelpers
|
public static class DateHelpers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the number of months between two dates.
|
||||||
|
/// </summary>
|
||||||
|
public static int MonthsBetween(DateOnly start, DateOnly end)
|
||||||
|
{
|
||||||
|
return ((end.Year - start.Year) * 12) + (end.Month - start.Month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the number of months between two dates.
|
||||||
|
/// </summary>
|
||||||
|
public static int MonthsBetween(DateTime start, DateTime end)
|
||||||
|
{
|
||||||
|
return ((end.Year - start.Year) * 12) + (end.Month - start.Month);
|
||||||
|
}
|
||||||
|
|
||||||
public static DateOnly? ParseDate(string? dateString)
|
public static DateOnly? ParseDate(string? dateString)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dateString))
|
if (string.IsNullOrWhiteSpace(dateString))
|
||||||
|
|||||||
32
src/TrueCV.Infrastructure/Helpers/JsonResponseHelper.cs
Normal file
32
src/TrueCV.Infrastructure/Helpers/JsonResponseHelper.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace TrueCV.Infrastructure.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for processing AI/LLM JSON responses.
|
||||||
|
/// </summary>
|
||||||
|
public static class JsonResponseHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans a JSON response by removing markdown code block formatting.
|
||||||
|
/// </summary>
|
||||||
|
public static string CleanJsonResponse(string response)
|
||||||
|
{
|
||||||
|
var trimmed = response.Trim();
|
||||||
|
|
||||||
|
// Remove markdown code blocks
|
||||||
|
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
trimmed = trimmed[7..];
|
||||||
|
}
|
||||||
|
else if (trimmed.StartsWith("```"))
|
||||||
|
{
|
||||||
|
trimmed = trimmed[3..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.EndsWith("```"))
|
||||||
|
{
|
||||||
|
trimmed = trimmed[..^3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -745,8 +745,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
if (seniorityJump >= 3)
|
if (seniorityJump >= 3)
|
||||||
{
|
{
|
||||||
// Calculate time between roles
|
// Calculate time between roles
|
||||||
var monthsBetween = ((currRole.StartDate!.Value.Year - prevRole.StartDate!.Value.Year) * 12) +
|
var monthsBetween = DateHelpers.MonthsBetween(prevRole.StartDate!.Value, currRole.StartDate!.Value);
|
||||||
(currRole.StartDate!.Value.Month - prevRole.StartDate!.Value.Month);
|
|
||||||
|
|
||||||
// If jumped 3+ levels in less than 2 years, flag it
|
// If jumped 3+ levels in less than 2 years, flag it
|
||||||
if (monthsBetween < 24)
|
if (monthsBetween < 24)
|
||||||
@@ -788,8 +787,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
|
|
||||||
foreach (var emp in employment.Where(e => e.StartDate.HasValue))
|
foreach (var emp in employment.Where(e => e.StartDate.HasValue))
|
||||||
{
|
{
|
||||||
var monthsAfterEducation = ((emp.StartDate!.Value.Year - latestEducationEnd.Year) * 12) +
|
var monthsAfterEducation = DateHelpers.MonthsBetween(latestEducationEnd, emp.StartDate!.Value);
|
||||||
(emp.StartDate!.Value.Month - latestEducationEnd.Month);
|
|
||||||
|
|
||||||
// Check if this is a senior role started within 2 years of finishing education
|
// Check if this is a senior role started within 2 years of finishing education
|
||||||
if (monthsAfterEducation < 24 && monthsAfterEducation >= 0)
|
if (monthsAfterEducation < 24 && monthsAfterEducation >= 0)
|
||||||
@@ -835,8 +833,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
if (role.StartDate.HasValue)
|
if (role.StartDate.HasValue)
|
||||||
{
|
{
|
||||||
var endDate = role.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
|
var endDate = role.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
var months = ((endDate.Year - role.StartDate.Value.Year) * 12) +
|
var months = DateHelpers.MonthsBetween(role.StartDate.Value, endDate);
|
||||||
(endDate.Month - role.StartDate.Value.Month);
|
|
||||||
totalMonths += Math.Max(0, months);
|
totalMonths += Math.Max(0, months);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -953,7 +950,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
.Select(e => e.EndDate ?? DateOnly.FromDateTime(DateTime.Today))
|
.Select(e => e.EndDate ?? DateOnly.FromDateTime(DateTime.Today))
|
||||||
.Max();
|
.Max();
|
||||||
|
|
||||||
var totalMonths = ((latestEnd.Year - earliestStart.Year) * 12) + (latestEnd.Month - earliestStart.Month);
|
var totalMonths = DateHelpers.MonthsBetween(earliestStart, latestEnd);
|
||||||
var years = totalMonths / 12;
|
var years = totalMonths / 12;
|
||||||
var months = totalMonths % 12;
|
var months = totalMonths % 12;
|
||||||
|
|
||||||
@@ -1011,8 +1008,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
|
|
||||||
if (lastRole?.EndDate != null)
|
if (lastRole?.EndDate != null)
|
||||||
{
|
{
|
||||||
var monthsSince = ((DateTime.Today.Year - lastRole.EndDate.Value.Year) * 12) +
|
var monthsSince = DateHelpers.MonthsBetween(lastRole.EndDate.Value, DateOnly.FromDateTime(DateTime.Today));
|
||||||
(DateTime.Today.Month - lastRole.EndDate.Value.Month);
|
|
||||||
|
|
||||||
if (monthsSince > 0)
|
if (monthsSince > 0)
|
||||||
{
|
{
|
||||||
@@ -1041,7 +1037,7 @@ public sealed class ProcessCVCheckJob
|
|||||||
.Select(e =>
|
.Select(e =>
|
||||||
{
|
{
|
||||||
var endDate = e.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
|
var endDate = e.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
var months = ((endDate.Year - e.StartDate!.Value.Year) * 12) + (endDate.Month - e.StartDate.Value.Month);
|
var months = DateHelpers.MonthsBetween(e.StartDate!.Value, endDate);
|
||||||
return new { Entry = e, Months = months };
|
return new { Entry = e, Months = months };
|
||||||
})
|
})
|
||||||
.Where(x => x.Months >= longTenureMonths)
|
.Where(x => x.Months >= longTenureMonths)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using TrueCV.Application.Helpers;
|
|||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Infrastructure.Configuration;
|
using TrueCV.Infrastructure.Configuration;
|
||||||
|
using TrueCV.Infrastructure.Helpers;
|
||||||
|
|
||||||
namespace TrueCV.Infrastructure.Services;
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
responseText = CleanJsonResponse(responseText);
|
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
|
||||||
|
|
||||||
var aiResponse = JsonSerializer.Deserialize<AIMatchResponse>(responseText, JsonDefaults.CamelCase);
|
var aiResponse = JsonSerializer.Deserialize<AIMatchResponse>(responseText, JsonDefaults.CamelCase);
|
||||||
|
|
||||||
@@ -163,25 +164,4 @@ public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
|
|||||||
return null; // Fall back to fuzzy matching
|
return null; // Fall back to fuzzy matching
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CleanJsonResponse(string response)
|
|
||||||
{
|
|
||||||
var trimmed = response.Trim();
|
|
||||||
|
|
||||||
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[7..];
|
|
||||||
}
|
|
||||||
else if (trimmed.StartsWith("```"))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[3..];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.EndsWith("```"))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[..^3];
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.Trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using TrueCV.Application.Helpers;
|
|||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Infrastructure.Configuration;
|
using TrueCV.Infrastructure.Configuration;
|
||||||
|
using TrueCV.Infrastructure.Helpers;
|
||||||
using UglyToad.PdfPig;
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
namespace TrueCV.Infrastructure.Services;
|
namespace TrueCV.Infrastructure.Services;
|
||||||
@@ -191,7 +192,7 @@ public sealed class CVParserService : ICVParserService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up response - remove markdown code blocks if present
|
// Clean up response - remove markdown code blocks if present
|
||||||
responseText = CleanJsonResponse(responseText);
|
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
|
||||||
|
|
||||||
_logger.LogDebug("Received response from Claude API, parsing JSON");
|
_logger.LogDebug("Received response from Claude API, parsing JSON");
|
||||||
|
|
||||||
@@ -213,28 +214,6 @@ public sealed class CVParserService : ICVParserService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CleanJsonResponse(string response)
|
|
||||||
{
|
|
||||||
var trimmed = response.Trim();
|
|
||||||
|
|
||||||
// Remove markdown code blocks
|
|
||||||
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[7..];
|
|
||||||
}
|
|
||||||
else if (trimmed.StartsWith("```"))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[3..];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.EndsWith("```"))
|
|
||||||
{
|
|
||||||
trimmed = trimmed[..^3];
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CVData MapToCVData(ClaudeCVResponse response)
|
private static CVData MapToCVData(ClaudeCVResponse response)
|
||||||
{
|
{
|
||||||
return new CVData
|
return new CVData
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using TrueCV.Application.Models;
|
|||||||
|
|
||||||
namespace TrueCV.Infrastructure.Services;
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
public class EducationVerifierService : IEducationVerifierService
|
public sealed class EducationVerifierService : IEducationVerifierService
|
||||||
{
|
{
|
||||||
private const int MinimumDegreeYears = 1;
|
private const int MinimumDegreeYears = 1;
|
||||||
private const int MaximumDegreeYears = 8;
|
private const int MaximumDegreeYears = 8;
|
||||||
|
|||||||
@@ -1,81 +1,3 @@
|
|||||||
.page {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
background-color: #f7f7f7;
|
|
||||||
border-bottom: 1px solid #d6d5d5;
|
|
||||||
justify-content: flex-end;
|
|
||||||
height: 3.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:first-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640.98px) {
|
|
||||||
.top-row {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.page {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 250px;
|
|
||||||
height: 100vh;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row.auth ::deep a:first-child {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row, article {
|
|
||||||
padding-left: 2rem !important;
|
|
||||||
padding-right: 1.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#blazor-error-ui {
|
#blazor-error-ui {
|
||||||
background: lightyellow;
|
background: lightyellow;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -88,9 +10,9 @@ main {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
#blazor-error-ui .dismiss {
|
#blazor-error-ui .dismiss {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject ILogger<Dashboard> Logger
|
@inject ILogger<Dashboard> Logger
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject TrueCV.Web.Services.PdfReportService PdfReportService
|
@inject TrueCV.Web.Services.IPdfReportService PdfReportService
|
||||||
@inject IAuditService AuditService
|
@inject IAuditService AuditService
|
||||||
|
|
||||||
<PageTitle>Dashboard - TrueCV</PageTitle>
|
<PageTitle>Dashboard - TrueCV</PageTitle>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@inject ILogger<Report> Logger
|
@inject ILogger<Report> Logger
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject IAuditService AuditService
|
@inject IAuditService AuditService
|
||||||
@inject PdfReportService PdfReportService
|
@inject IPdfReportService PdfReportService
|
||||||
|
|
||||||
<PageTitle>Verification Report - TrueCV</PageTitle>
|
<PageTitle>Verification Report - TrueCV</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
@using TrueCV.Application.Models
|
|
||||||
|
|
||||||
<div class="employment-table-wrapper">
|
|
||||||
@if (Verifications is null || Verifications.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="employment-empty">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-briefcase text-muted mb-3" viewBox="0 0 16 16">
|
|
||||||
<path d="M6.5 1A1.5 1.5 0 0 0 5 2.5V3H1.5A1.5 1.5 0 0 0 0 4.5v8A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-8A1.5 1.5 0 0 0 14.5 3H11v-.5A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V3H6zM1 4.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5V7H1zM1 8h14v4.5a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-muted">No employment verification data available</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="employment-summary mb-3">
|
|
||||||
@{
|
|
||||||
var verifiedCount = Verifications.Count(v => v.IsVerified);
|
|
||||||
var unverifiedCount = Verifications.Count - verifiedCount;
|
|
||||||
}
|
|
||||||
<span class="badge bg-success me-2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-lg me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/>
|
|
||||||
</svg>
|
|
||||||
@verifiedCount Verified
|
|
||||||
</span>
|
|
||||||
@if (unverifiedCount > 0)
|
|
||||||
{
|
|
||||||
<span class="badge bg-danger">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-lg me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
|
|
||||||
</svg>
|
|
||||||
@unverifiedCount Unverified
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover employment-table mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Company Claimed</th>
|
|
||||||
<th scope="col">Matched Company</th>
|
|
||||||
<th scope="col">Match Score</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Dates</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var verification in Verifications)
|
|
||||||
{
|
|
||||||
<tr class="@(verification.IsVerified ? "" : "table-warning")">
|
|
||||||
<td>
|
|
||||||
<span class="company-name">@verification.ClaimedCompany</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyName))
|
|
||||||
{
|
|
||||||
<span class="matched-company">
|
|
||||||
@verification.MatchedCompanyName
|
|
||||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyNumber))
|
|
||||||
{
|
|
||||||
<small class="text-muted d-block">@verification.MatchedCompanyNumber</small>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="text-muted">No match found</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="match-score">
|
|
||||||
<div class="progress" style="width: 80px; height: 6px;">
|
|
||||||
<div class="progress-bar @GetMatchScoreClass(verification.MatchScore)"
|
|
||||||
role="progressbar"
|
|
||||||
style="width: @verification.MatchScore%"
|
|
||||||
aria-valuenow="@verification.MatchScore"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="match-score-value @GetMatchScoreTextClass(verification.MatchScore)">
|
|
||||||
@verification.MatchScore%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (verification.IsVerified)
|
|
||||||
{
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
||||||
</svg>
|
|
||||||
Verified
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="badge bg-danger">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
|
|
||||||
</svg>
|
|
||||||
Unverified
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="employment-dates">
|
|
||||||
@FormatDateRange(verification.ClaimedStartDate, verification.ClaimedEndDate)
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@if (!string.IsNullOrEmpty(verification.VerificationNotes))
|
|
||||||
{
|
|
||||||
<tr class="verification-notes-row @(verification.IsVerified ? "" : "table-warning")">
|
|
||||||
<td colspan="5" class="verification-notes">
|
|
||||||
<small>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-info-circle me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
|
||||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
|
|
||||||
</svg>
|
|
||||||
@verification.VerificationNotes
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.employment-table-wrapper {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-summary {
|
|
||||||
padding: 1rem 1rem 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-table {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-table th {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
color: #495057;
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matched-company {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-score {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-score-value {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
min-width: 35px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-dates {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: #6c757d;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verification-notes-row td {
|
|
||||||
padding-top: 0 !important;
|
|
||||||
border-top: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verification-notes {
|
|
||||||
color: #6c757d;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-warning {
|
|
||||||
--bs-table-bg: #fff3cd;
|
|
||||||
--bs-table-striped-bg: #f2e6be;
|
|
||||||
--bs-table-hover-bg: #ecdeb1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public List<CompanyVerificationResult>? Verifications { get; set; }
|
|
||||||
|
|
||||||
private static string GetMatchScoreClass(int score)
|
|
||||||
{
|
|
||||||
return score switch
|
|
||||||
{
|
|
||||||
>= 80 => "bg-success",
|
|
||||||
>= 60 => "bg-info",
|
|
||||||
>= 40 => "bg-warning",
|
|
||||||
_ => "bg-danger"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetMatchScoreTextClass(int score)
|
|
||||||
{
|
|
||||||
return score switch
|
|
||||||
{
|
|
||||||
>= 80 => "text-success",
|
|
||||||
>= 60 => "text-info",
|
|
||||||
>= 40 => "text-warning",
|
|
||||||
_ => "text-danger"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDateRange(DateOnly? startDate, DateOnly? endDate)
|
|
||||||
{
|
|
||||||
if (!startDate.HasValue && !endDate.HasValue)
|
|
||||||
{
|
|
||||||
return "Dates not specified";
|
|
||||||
}
|
|
||||||
|
|
||||||
var start = startDate?.ToString("MMM yyyy") ?? "Unknown";
|
|
||||||
var end = endDate?.ToString("MMM yyyy") ?? "Present";
|
|
||||||
|
|
||||||
return $"{start} - {end}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
@using TrueCV.Application.Models
|
|
||||||
|
|
||||||
<div class="flags-list">
|
|
||||||
@if (Flags is null || Flags.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="flags-empty">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-check-circle text-success mb-3" viewBox="0 0 16 16">
|
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
|
||||||
<path d="m10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-muted">No flags found</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="flags-summary mb-3">
|
|
||||||
@{
|
|
||||||
var criticalCount = GetFlagsBySeverity("Critical").Count;
|
|
||||||
var warningCount = GetFlagsBySeverity("Warning").Count;
|
|
||||||
var infoCount = GetFlagsBySeverity("Info").Count;
|
|
||||||
}
|
|
||||||
@if (criticalCount > 0)
|
|
||||||
{
|
|
||||||
<span class="badge bg-danger me-2">@criticalCount Critical</span>
|
|
||||||
}
|
|
||||||
@if (warningCount > 0)
|
|
||||||
{
|
|
||||||
<span class="badge bg-warning text-dark me-2">@warningCount Warning</span>
|
|
||||||
}
|
|
||||||
@if (infoCount > 0)
|
|
||||||
{
|
|
||||||
<span class="badge bg-info text-dark">@infoCount Info</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flags-items">
|
|
||||||
@foreach (var flag in GetSortedFlags())
|
|
||||||
{
|
|
||||||
<div class="flag-item @GetFlagSeverityClass(flag.Severity)">
|
|
||||||
<div class="flag-icon">
|
|
||||||
@GetFlagIcon(flag.Severity)
|
|
||||||
</div>
|
|
||||||
<div class="flag-content">
|
|
||||||
<div class="flag-header">
|
|
||||||
<span class="flag-title">@flag.Title</span>
|
|
||||||
<span class="flag-category badge bg-secondary">@flag.Category</span>
|
|
||||||
</div>
|
|
||||||
<p class="flag-description">@flag.Description</p>
|
|
||||||
<div class="flag-impact">
|
|
||||||
<span class="flag-impact-label">Score Impact:</span>
|
|
||||||
<span class="flag-impact-value @(flag.ScoreImpact < 0 ? "text-danger" : "text-success")">
|
|
||||||
@(flag.ScoreImpact > 0 ? "+" : "")@flag.ScoreImpact
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.flags-list {
|
|
||||||
background-color: var(--truecv-bg-surface);
|
|
||||||
border: 1px solid var(--truecv-gray-200);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flags-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flags-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-left: 4px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item.critical {
|
|
||||||
background-color: #fdf2f2;
|
|
||||||
border-left-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item.warning {
|
|
||||||
background-color: #fdfbf0;
|
|
||||||
border-left-color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item.info {
|
|
||||||
background-color: #f0f5fa;
|
|
||||||
border-left-color: #0dcaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item.critical .flag-icon { color: #dc3545; }
|
|
||||||
.flag-item.warning .flag-icon { color: #856404; }
|
|
||||||
.flag-item.info .flag-icon { color: #055160; }
|
|
||||||
|
|
||||||
.flag-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-item.critical .flag-title { color: #842029; }
|
|
||||||
.flag-item.warning .flag-title { color: #664d03; }
|
|
||||||
.flag-item.info .flag-title { color: #055160; }
|
|
||||||
|
|
||||||
.flag-category {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-description {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-impact {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-impact-label {
|
|
||||||
color: #6c757d;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-impact-value {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public List<FlagResult>? Flags { get; set; }
|
|
||||||
|
|
||||||
private List<FlagResult> GetSortedFlags()
|
|
||||||
{
|
|
||||||
if (Flags is null || Flags.Count == 0)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Flags
|
|
||||||
.OrderBy(f => GetSeverityOrder(f.Severity))
|
|
||||||
.ThenBy(f => f.ScoreImpact)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<FlagResult> GetFlagsBySeverity(string severity)
|
|
||||||
{
|
|
||||||
if (Flags is null)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Flags.Where(f => f.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetSeverityOrder(string severity)
|
|
||||||
{
|
|
||||||
return severity.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"critical" => 0,
|
|
||||||
"warning" => 1,
|
|
||||||
"info" => 2,
|
|
||||||
_ => 3
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetFlagSeverityClass(string severity)
|
|
||||||
{
|
|
||||||
return severity.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"critical" => "critical",
|
|
||||||
"warning" => "warning",
|
|
||||||
"info" => "info",
|
|
||||||
_ => "info"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MarkupString GetFlagIcon(string severity)
|
|
||||||
{
|
|
||||||
var icon = severity.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"critical" => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-octagon-fill" viewBox="0 0 16 16"><path d="M11.46.146A.5.5 0 0 0 11.107 0H4.893a.5.5 0 0 0-.353.146L.146 4.54A.5.5 0 0 0 0 4.893v6.214a.5.5 0 0 0 .146.353l4.394 4.394a.5.5 0 0 0 .353.146h6.214a.5.5 0 0 0 .353-.146l4.394-4.394a.5.5 0 0 0 .146-.353V4.893a.5.5 0 0 0-.146-.353zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/></svg>""",
|
|
||||||
"warning" => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/></svg>""",
|
|
||||||
_ => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-info-circle-fill" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2"/></svg>"""
|
|
||||||
};
|
|
||||||
return new MarkupString(icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
@using TrueCV.Application.Models
|
|
||||||
|
|
||||||
<div class="timeline-visualization">
|
|
||||||
@if (Employment is null || Employment.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="timeline-empty">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-calendar-range text-muted mb-3" viewBox="0 0 16 16">
|
|
||||||
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1M1 9h4a1 1 0 0 1 0 2H1z"/>
|
|
||||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-muted">No employment timeline data available</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="timeline-header mb-3">
|
|
||||||
<div class="timeline-legend">
|
|
||||||
<span class="legend-item">
|
|
||||||
<span class="legend-color employment"></span>
|
|
||||||
Employment
|
|
||||||
</span>
|
|
||||||
@if (Analysis?.Gaps?.Count > 0)
|
|
||||||
{
|
|
||||||
<span class="legend-item">
|
|
||||||
<span class="legend-color gap"></span>
|
|
||||||
Gap (@Analysis.TotalGapMonths months total)
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (Analysis?.Overlaps?.Count > 0)
|
|
||||||
{
|
|
||||||
<span class="legend-item">
|
|
||||||
<span class="legend-color overlap"></span>
|
|
||||||
Overlap (@Analysis.TotalOverlapMonths months total)
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-container">
|
|
||||||
@{
|
|
||||||
var timelineData = GetTimelineData();
|
|
||||||
var minDate = timelineData.MinDate;
|
|
||||||
var maxDate = timelineData.MaxDate;
|
|
||||||
var totalMonths = GetMonthsDifference(minDate, maxDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="timeline-axis">
|
|
||||||
@foreach (var year in GetYearMarkers(minDate, maxDate))
|
|
||||||
{
|
|
||||||
var position = GetPositionPercentage(year, minDate, totalMonths);
|
|
||||||
<div class="timeline-year-marker" style="left: @position%;">
|
|
||||||
<span class="year-label">@year.Year</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-bars">
|
|
||||||
@foreach (var entry in Employment.OrderBy(e => e.StartDate ?? DateOnly.MaxValue))
|
|
||||||
{
|
|
||||||
var startDate = entry.StartDate ?? minDate;
|
|
||||||
var endDate = entry.EndDate ?? (entry.IsCurrent ? DateOnly.FromDateTime(DateTime.Today) : maxDate);
|
|
||||||
var left = GetPositionPercentage(startDate, minDate, totalMonths);
|
|
||||||
var width = GetWidthPercentage(startDate, endDate, totalMonths);
|
|
||||||
|
|
||||||
<div class="timeline-bar-row">
|
|
||||||
<div class="timeline-bar employment-bar"
|
|
||||||
style="left: @left%; width: @width%;"
|
|
||||||
title="@entry.CompanyName: @FormatDateRange(entry.StartDate, entry.EndDate, entry.IsCurrent)">
|
|
||||||
<span class="bar-label">@entry.CompanyName</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Analysis?.Gaps?.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="timeline-gaps">
|
|
||||||
@foreach (var gap in Analysis.Gaps)
|
|
||||||
{
|
|
||||||
var left = GetPositionPercentage(gap.StartDate, minDate, totalMonths);
|
|
||||||
var width = GetWidthPercentage(gap.StartDate, gap.EndDate, totalMonths);
|
|
||||||
|
|
||||||
<div class="timeline-gap-marker"
|
|
||||||
style="left: @left%; width: @width%;"
|
|
||||||
title="Gap: @gap.Months months (@gap.StartDate.ToString("MMM yyyy") - @gap.EndDate.ToString("MMM yyyy"))">
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Analysis?.Overlaps?.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="timeline-overlaps">
|
|
||||||
@foreach (var overlap in Analysis.Overlaps)
|
|
||||||
{
|
|
||||||
var left = GetPositionPercentage(overlap.OverlapStart, minDate, totalMonths);
|
|
||||||
var width = GetWidthPercentage(overlap.OverlapStart, overlap.OverlapEnd, totalMonths);
|
|
||||||
|
|
||||||
<div class="timeline-overlap-marker"
|
|
||||||
style="left: @left%; width: @width%;"
|
|
||||||
title="Overlap: @overlap.Company1 & @overlap.Company2 (@overlap.Months months)">
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Analysis is not null && (Analysis.Gaps.Count > 0 || Analysis.Overlaps.Count > 0))
|
|
||||||
{
|
|
||||||
<div class="timeline-details mt-4">
|
|
||||||
@if (Analysis.Gaps.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="timeline-detail-section">
|
|
||||||
<h6 class="detail-title text-warning">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle me-2" viewBox="0 0 16 16">
|
|
||||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z"/>
|
|
||||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
|
|
||||||
</svg>
|
|
||||||
Employment Gaps
|
|
||||||
</h6>
|
|
||||||
<ul class="detail-list">
|
|
||||||
@foreach (var gap in Analysis.Gaps)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<strong>@gap.Months months</strong> gap from @gap.StartDate.ToString("MMM yyyy") to @gap.EndDate.ToString("MMM yyyy")
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Analysis.Overlaps.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="timeline-detail-section">
|
|
||||||
<h6 class="detail-title text-danger">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-layers me-2" viewBox="0 0 16 16">
|
|
||||||
<path d="M8.235 1.559a.5.5 0 0 0-.47 0l-7.5 4a.5.5 0 0 0 0 .882L3.188 8 .264 9.559a.5.5 0 0 0 0 .882l7.5 4a.5.5 0 0 0 .47 0l7.5-4a.5.5 0 0 0 0-.882L12.813 8l2.922-1.559a.5.5 0 0 0 0-.882zm3.515 7.008L14.438 10 8 13.433 1.562 10 4.25 8.567l3.515 1.874a.5.5 0 0 0 .47 0zM8 9.433 1.562 6 8 2.567 14.438 6z"/>
|
|
||||||
</svg>
|
|
||||||
Employment Overlaps
|
|
||||||
</h6>
|
|
||||||
<ul class="detail-list">
|
|
||||||
@foreach (var overlap in Analysis.Overlaps)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<strong>@overlap.Months months</strong> overlap between <em>@overlap.Company1</em> and <em>@overlap.Company2</em>
|
|
||||||
(@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy"))
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.timeline-visualization {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color.employment { background-color: #0d6efd; }
|
|
||||||
.legend-color.gap { background-color: #fd7e14; }
|
|
||||||
.legend-color.overlap { background-color: #dc3545; }
|
|
||||||
|
|
||||||
.timeline-container {
|
|
||||||
position: relative;
|
|
||||||
padding: 2rem 0;
|
|
||||||
min-height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-axis {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 30px;
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-year-marker {
|
|
||||||
position: absolute;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-year-marker::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 1px;
|
|
||||||
height: 10px;
|
|
||||||
background-color: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.year-label {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-bars {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-bar-row {
|
|
||||||
position: relative;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-bar {
|
|
||||||
position: absolute;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employment-bar {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-gaps {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 30px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-gap-marker {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(253, 126, 20, 0.2);
|
|
||||||
border-left: 2px dashed #fd7e14;
|
|
||||||
border-right: 2px dashed #fd7e14;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-overlaps {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 30px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-overlap-marker {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(220, 53, 69, 0.15);
|
|
||||||
border-left: 2px solid #dc3545;
|
|
||||||
border-right: 2px solid #dc3545;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-details {
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-detail-section {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-detail-section:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-list {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-list li {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-list li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public TimelineAnalysisResult? Analysis { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public List<EmploymentEntry>? Employment { get; set; }
|
|
||||||
|
|
||||||
private (DateOnly MinDate, DateOnly MaxDate) GetTimelineData()
|
|
||||||
{
|
|
||||||
if (Employment is null || Employment.Count == 0)
|
|
||||||
{
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
|
||||||
return (today.AddYears(-5), today);
|
|
||||||
}
|
|
||||||
|
|
||||||
var dates = new List<DateOnly>();
|
|
||||||
|
|
||||||
foreach (var entry in Employment)
|
|
||||||
{
|
|
||||||
if (entry.StartDate.HasValue)
|
|
||||||
{
|
|
||||||
dates.Add(entry.StartDate.Value);
|
|
||||||
}
|
|
||||||
if (entry.EndDate.HasValue)
|
|
||||||
{
|
|
||||||
dates.Add(entry.EndDate.Value);
|
|
||||||
}
|
|
||||||
else if (entry.IsCurrent)
|
|
||||||
{
|
|
||||||
dates.Add(DateOnly.FromDateTime(DateTime.Today));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dates.Count == 0)
|
|
||||||
{
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
|
||||||
return (today.AddYears(-5), today);
|
|
||||||
}
|
|
||||||
|
|
||||||
var minDate = dates.Min().AddMonths(-3);
|
|
||||||
var maxDate = dates.Max().AddMonths(3);
|
|
||||||
|
|
||||||
return (minDate, maxDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetMonthsDifference(DateOnly start, DateOnly end)
|
|
||||||
{
|
|
||||||
return ((end.Year - start.Year) * 12) + end.Month - start.Month;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double GetPositionPercentage(DateOnly date, DateOnly minDate, int totalMonths)
|
|
||||||
{
|
|
||||||
if (totalMonths == 0) return 0;
|
|
||||||
var months = GetMonthsDifference(minDate, date);
|
|
||||||
return Math.Max(0, Math.Min(100, (double)months / totalMonths * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double GetWidthPercentage(DateOnly start, DateOnly end, int totalMonths)
|
|
||||||
{
|
|
||||||
if (totalMonths == 0) return 0;
|
|
||||||
var months = GetMonthsDifference(start, end);
|
|
||||||
return Math.Max(1, Math.Min(100, (double)months / totalMonths * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<DateOnly> GetYearMarkers(DateOnly minDate, DateOnly maxDate)
|
|
||||||
{
|
|
||||||
var startYear = minDate.Year;
|
|
||||||
var endYear = maxDate.Year;
|
|
||||||
|
|
||||||
for (var year = startYear; year <= endYear; year++)
|
|
||||||
{
|
|
||||||
yield return new DateOnly(year, 1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDateRange(DateOnly? startDate, DateOnly? endDate, bool isCurrent)
|
|
||||||
{
|
|
||||||
var start = startDate?.ToString("MMM yyyy") ?? "Unknown";
|
|
||||||
var end = isCurrent ? "Present" : (endDate?.ToString("MMM yyyy") ?? "Unknown");
|
|
||||||
return $"{start} - {end}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
@implements IDisposable
|
|
||||||
|
|
||||||
<div class="veracity-score-card">
|
|
||||||
<div class="score-circle @GetScoreColorClass()">
|
|
||||||
<svg viewBox="0 0 36 36" class="score-chart">
|
|
||||||
<path class="score-background"
|
|
||||||
d="M18 2.0845
|
|
||||||
a 15.9155 15.9155 0 0 1 0 31.831
|
|
||||||
a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
||||||
<path class="score-progress"
|
|
||||||
stroke-dasharray="@_displayScore, 100"
|
|
||||||
d="M18 2.0845
|
|
||||||
a 15.9155 15.9155 0 0 1 0 31.831
|
|
||||||
a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
||||||
</svg>
|
|
||||||
<div class="score-value">
|
|
||||||
<span class="score-number">@_displayScore</span>
|
|
||||||
<span class="score-max">/100</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="score-label @GetScoreColorClass()">
|
|
||||||
@(string.IsNullOrEmpty(Label) ? GetDefaultLabel() : Label)
|
|
||||||
</div>
|
|
||||||
<div class="score-description">
|
|
||||||
@GetScoreDescription()
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.veracity-score-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: var(--truecv-bg-surface);
|
|
||||||
border: 1px solid var(--truecv-gray-200);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-circle {
|
|
||||||
position: relative;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-chart {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-background {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--truecv-gray-200);
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-progress {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 3;
|
|
||||||
stroke-linecap: round;
|
|
||||||
transition: stroke-dasharray 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-circle.excellent .score-progress { stroke: #198754; }
|
|
||||||
.score-circle.good .score-progress { stroke: #20c997; }
|
|
||||||
.score-circle.moderate .score-progress { stroke: #ffc107; }
|
|
||||||
.score-circle.poor .score-progress { stroke: #dc3545; }
|
|
||||||
|
|
||||||
.score-value {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-number {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-circle.excellent .score-number { color: #198754; }
|
|
||||||
.score-circle.good .score-number { color: #20c997; }
|
|
||||||
.score-circle.moderate .score-number { color: #ffc107; }
|
|
||||||
.score-circle.poor .score-number { color: #dc3545; }
|
|
||||||
|
|
||||||
.score-max {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label.excellent { color: #198754; }
|
|
||||||
.score-label.good { color: #20c997; }
|
|
||||||
.score-label.moderate { color: #ffc107; }
|
|
||||||
.score-label.poor { color: #dc3545; }
|
|
||||||
|
|
||||||
.score-description {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private int _displayScore;
|
|
||||||
private System.Threading.Timer? _animationTimer;
|
|
||||||
private int _targetScore;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public int Score { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string? Label { get; set; }
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
_targetScore = Math.Clamp(Score, 0, 100);
|
|
||||||
|
|
||||||
if (_displayScore != _targetScore)
|
|
||||||
{
|
|
||||||
StartAnimation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartAnimation()
|
|
||||||
{
|
|
||||||
_animationTimer?.Dispose();
|
|
||||||
|
|
||||||
var increment = _targetScore > _displayScore ? 1 : -1;
|
|
||||||
var intervalMs = Math.Max(10, 500 / Math.Max(1, Math.Abs(_targetScore - _displayScore)));
|
|
||||||
|
|
||||||
_animationTimer = new System.Threading.Timer(_ =>
|
|
||||||
{
|
|
||||||
if (_displayScore == _targetScore)
|
|
||||||
{
|
|
||||||
_animationTimer?.Dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_displayScore += increment;
|
|
||||||
InvokeAsync(StateHasChanged);
|
|
||||||
}, null, 0, intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetScoreColorClass()
|
|
||||||
{
|
|
||||||
return _targetScore switch
|
|
||||||
{
|
|
||||||
>= 90 => "excellent",
|
|
||||||
>= 70 => "good",
|
|
||||||
>= 50 => "moderate",
|
|
||||||
_ => "poor"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetDefaultLabel()
|
|
||||||
{
|
|
||||||
return _targetScore switch
|
|
||||||
{
|
|
||||||
>= 90 => "Excellent",
|
|
||||||
>= 70 => "Good",
|
|
||||||
>= 50 => "Moderate",
|
|
||||||
_ => "Poor"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetScoreDescription()
|
|
||||||
{
|
|
||||||
return _targetScore switch
|
|
||||||
{
|
|
||||||
>= 90 => "This CV demonstrates high veracity with minimal concerns.",
|
|
||||||
>= 70 => "This CV is generally trustworthy with some minor concerns.",
|
|
||||||
>= 50 => "This CV has some inconsistencies that may need clarification.",
|
|
||||||
_ => "This CV has significant concerns that require attention."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_animationTimer?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
@using TrueCV.Domain.Enums
|
|
||||||
|
|
||||||
<div class="verification-progress">
|
|
||||||
<div class="verification-progress-header">
|
|
||||||
@if (!string.IsNullOrEmpty(FileName))
|
|
||||||
{
|
|
||||||
<span class="verification-progress-filename" title="@FileName">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text me-2" viewBox="0 0 16 16">
|
|
||||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/>
|
|
||||||
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
|
|
||||||
</svg>
|
|
||||||
@FileName
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="verification-progress-status">
|
|
||||||
@switch (Status)
|
|
||||||
{
|
|
||||||
case CheckStatus.Pending:
|
|
||||||
<div class="status-icon status-pending">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hourglass" viewBox="0 0 16 16">
|
|
||||||
<path d="M2 1.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1-.5-.5m2.5.5v1a3.5 3.5 0 0 0 1.989 3.158c.533.256 1.011.791 1.011 1.491v.702c0 .7-.478 1.235-1.011 1.491A3.5 3.5 0 0 0 4.5 13v1h7v-1a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351v-.702c0-.7.478-1.235 1.011-1.491A3.5 3.5 0 0 0 11.5 3V2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="status-text">
|
|
||||||
<span class="status-title">Pending</span>
|
|
||||||
<span class="status-description">Your CV is queued for verification</span>
|
|
||||||
</div>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case CheckStatus.Processing:
|
|
||||||
<div class="status-icon status-processing">
|
|
||||||
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
|
|
||||||
<span class="visually-hidden">Processing...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-text">
|
|
||||||
<span class="status-title">Processing</span>
|
|
||||||
<span class="status-description">Verifying your CV data...</span>
|
|
||||||
</div>
|
|
||||||
<div class="processing-steps mt-3">
|
|
||||||
<div class="processing-step active">
|
|
||||||
<div class="step-indicator"></div>
|
|
||||||
<span>Extracting CV data</span>
|
|
||||||
</div>
|
|
||||||
<div class="processing-step">
|
|
||||||
<div class="step-indicator"></div>
|
|
||||||
<span>Verifying companies</span>
|
|
||||||
</div>
|
|
||||||
<div class="processing-step">
|
|
||||||
<div class="step-indicator"></div>
|
|
||||||
<span>Analysing timeline</span>
|
|
||||||
</div>
|
|
||||||
<div class="processing-step">
|
|
||||||
<div class="step-indicator"></div>
|
|
||||||
<span>Generating report</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case CheckStatus.Completed:
|
|
||||||
<div class="status-icon status-completed">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="status-text">
|
|
||||||
<span class="status-title text-success">Completed</span>
|
|
||||||
<span class="status-description">Verification complete! View your results below.</span>
|
|
||||||
</div>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case CheckStatus.Failed:
|
|
||||||
<div class="status-icon status-failed">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-x-circle-fill text-danger" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="status-text">
|
|
||||||
<span class="status-title text-danger">Failed</span>
|
|
||||||
<span class="status-description">Something went wrong. Please try again.</span>
|
|
||||||
</div>
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.verification-progress {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verification-progress-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verification-progress-filename {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #495057;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verification-progress-status {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pending svg {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-description {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-steps {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-step {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #adb5bd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-step.active {
|
|
||||||
color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-step.active .step-indicator {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-indicator {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #dee2e6;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public CheckStatus Status { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string? FileName { get; set; }
|
|
||||||
}
|
|
||||||
12
src/TrueCV.Web/HangfireAuthorizationFilter.cs
Normal file
12
src/TrueCV.Web/HangfireAuthorizationFilter.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Hangfire.Dashboard;
|
||||||
|
|
||||||
|
namespace TrueCV.Web;
|
||||||
|
|
||||||
|
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
||||||
|
{
|
||||||
|
public bool Authorize(DashboardContext context)
|
||||||
|
{
|
||||||
|
var httpContext = context.GetHttpContext();
|
||||||
|
return httpContext.User.Identity?.IsAuthenticated ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Threading.RateLimiting;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TrueCV.Infrastructure;
|
using TrueCV.Infrastructure;
|
||||||
@@ -35,7 +37,7 @@ try
|
|||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// Add Web services
|
// Add Web services
|
||||||
builder.Services.AddScoped<PdfReportService>();
|
builder.Services.AddScoped<IPdfReportService, PdfReportService>();
|
||||||
|
|
||||||
// Add Identity with secure password requirements
|
// Add Identity with secure password requirements
|
||||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
||||||
@@ -69,15 +71,30 @@ try
|
|||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<ApplicationDbContext>("database");
|
.AddDbContextCheck<ApplicationDbContext>("database");
|
||||||
|
|
||||||
|
// Add rate limiting for login endpoint
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddFixedWindowLimiter("login", limiterOptions =>
|
||||||
|
{
|
||||||
|
limiterOptions.PermitLimit = 5;
|
||||||
|
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
limiterOptions.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Seed default admin user and clear company cache
|
// Seed default admin user and clear company cache
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
var defaultEmail = "admin@truecv.local";
|
var defaultEmail = builder.Configuration["DefaultAdmin:Email"];
|
||||||
var defaultPassword = "TrueCV_Admin123!";
|
var defaultPassword = builder.Configuration["DefaultAdmin:Password"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(defaultEmail) && !string.IsNullOrEmpty(defaultPassword))
|
||||||
|
{
|
||||||
if (await userManager.FindByEmailAsync(defaultEmail) == null)
|
if (await userManager.FindByEmailAsync(defaultEmail) == null)
|
||||||
{
|
{
|
||||||
var adminUser = new ApplicationUser
|
var adminUser = new ApplicationUser
|
||||||
@@ -86,9 +103,21 @@ try
|
|||||||
Email = defaultEmail,
|
Email = defaultEmail,
|
||||||
EmailConfirmed = true
|
EmailConfirmed = true
|
||||||
};
|
};
|
||||||
await userManager.CreateAsync(adminUser, defaultPassword);
|
var result = await userManager.CreateAsync(adminUser, defaultPassword);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
Log.Information("Created default admin user: {Email}", defaultEmail);
|
Log.Information("Created default admin user: {Email}", defaultEmail);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Warning("Failed to create admin user: {Errors}", string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("No default admin credentials configured - skipping admin user seeding");
|
||||||
|
}
|
||||||
|
|
||||||
// Clear company cache on startup to ensure fresh API lookups
|
// Clear company cache on startup to ensure fresh API lookups
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
@@ -127,13 +156,14 @@ try
|
|||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
// Add Hangfire Dashboard (only in development)
|
// Add Hangfire Dashboard (only in development, requires authentication)
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
{
|
{
|
||||||
Authorization = [] // Allow anonymous access in development
|
Authorization = [new HangfireAuthorizationFilter()]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +203,7 @@ try
|
|||||||
Log.Warning("Failed login attempt for {Email}", email);
|
Log.Warning("Failed login attempt for {Email}", email);
|
||||||
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
|
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
|
||||||
}
|
}
|
||||||
});
|
}).RequireRateLimiting("login");
|
||||||
|
|
||||||
// Logout endpoint
|
// Logout endpoint
|
||||||
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
||||||
|
|||||||
9
src/TrueCV.Web/Services/IPdfReportService.cs
Normal file
9
src/TrueCV.Web/Services/IPdfReportService.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using TrueCV.Application.Models;
|
||||||
|
|
||||||
|
namespace TrueCV.Web.Services;
|
||||||
|
|
||||||
|
public interface IPdfReportService
|
||||||
|
{
|
||||||
|
byte[] GenerateSingleReport(string candidateName, VeracityReport report);
|
||||||
|
byte[] GenerateReport(List<PdfReportData> data);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using QuestPDF.Fluent;
|
using QuestPDF.Fluent;
|
||||||
using QuestPDF.Helpers;
|
using QuestPDF.Helpers;
|
||||||
using QuestPDF.Infrastructure;
|
using QuestPDF.Infrastructure;
|
||||||
|
using TrueCV.Application.Helpers;
|
||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
|
|
||||||
namespace TrueCV.Web.Services;
|
namespace TrueCV.Web.Services;
|
||||||
|
|
||||||
public class PdfReportService
|
public class PdfReportService : IPdfReportService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a detailed PDF report for a single CV verification.
|
/// Generates a detailed PDF report for a single CV verification.
|
||||||
@@ -33,8 +34,8 @@ public class PdfReportService
|
|||||||
|
|
||||||
private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report)
|
private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report)
|
||||||
{
|
{
|
||||||
var scoreColor = report.OverallScore > 70 ? Colors.Green.Darken1 :
|
var scoreColor = report.OverallScore > ScoreThresholds.High ? Colors.Green.Darken1 :
|
||||||
(report.OverallScore >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
(report.OverallScore >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
||||||
|
|
||||||
container.Column(column =>
|
container.Column(column =>
|
||||||
{
|
{
|
||||||
@@ -282,7 +283,7 @@ public class PdfReportService
|
|||||||
foreach (var item in data)
|
foreach (var item in data)
|
||||||
{
|
{
|
||||||
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
|
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
|
||||||
var scoreColor = item.Score > 70 ? Colors.Green.Darken1 : (item.Score >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
var scoreColor = item.Score > ScoreThresholds.High ? Colors.Green.Darken1 : (item.Score >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
|
||||||
|
|
||||||
table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName);
|
table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName);
|
||||||
table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy"));
|
table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy"));
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"Anthropic": {
|
"Anthropic": {
|
||||||
"ApiKey": ""
|
"ApiKey": ""
|
||||||
},
|
},
|
||||||
|
"DefaultAdmin": {
|
||||||
|
"Email": "",
|
||||||
|
"Password": ""
|
||||||
|
},
|
||||||
"AzureBlob": {
|
"AzureBlob": {
|
||||||
"ConnectionString": "",
|
"ConnectionString": "",
|
||||||
"ContainerName": "cv-uploads"
|
"ContainerName": "cv-uploads"
|
||||||
|
|||||||
Reference in New Issue
Block a user