diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index eb932d5..093822a 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -78,9 +78,56 @@ public sealed class AdminSystemController : ControllerBase var dbPath = _paths.GetDbPath(); var dbFile = new FileInfo(dbPath); - var jobs = await _db.JobApplications.AsNoTracking().ToListAsync(cancellationToken); - var companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken); - var ai = await _summarizer.GetMetricsAsync(cancellationToken); + var jobCount = 0; + var deletedCount = 0; + var companies = 0; + string? statusWarning = null; + try + { + jobCount = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken); + deletedCount = await _db.JobApplications.AsNoTracking().CountAsync(x => x.IsDeleted, cancellationToken); + companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken); + } + catch (Exception ex) + { + statusWarning = $"Data query failed: {ex.GetType().Name}"; + } + + AiServiceMetrics ai; + try + { + ai = await _summarizer.GetMetricsAsync(cancellationToken); + } + catch (Exception ex) + { + ai = new AiServiceMetrics( + Healthy: false, + Model: null, + Device: null, + GpuAvailable: false, + GpuName: null, + OcrAvailable: false, + OcrLanguages: null, + HealthLatencyMs: null, + ProbeLatencyMs: null, + LastProbeAt: null, + LastProbeSuccessAt: null, + LastProbeFailureAt: null, + ProbeFailures: 0, + Requests: 0, + CacheHits: 0, + CacheMisses: 0, + Failures: 0, + AverageLatencyMs: null, + OcrRequests: 0, + OcrFailures: 0, + AverageOcrLatencyMs: null, + LastOcrSuccessAt: null, + LastOcrFailureAt: null, + LastSuccessAt: null, + LastFailureAt: null, + LastError: ex.Message); + } var version = NormalizeBuildMetadata(_cfg["App:Version"]); if (string.IsNullOrWhiteSpace(version)) @@ -130,11 +177,18 @@ public sealed class AdminSystemController : ControllerBase { dbWarning = "Database connection failed."; } - else if (!usesFileStorage && jobs.Count == 0 && companies == 0) + else if (!usesFileStorage && jobCount == 0 && companies == 0) { dbWarning = "Connected, but no data is present yet. Check whether this is the intended production database."; } + if (!string.IsNullOrWhiteSpace(statusWarning)) + { + dbWarning = string.IsNullOrWhiteSpace(dbWarning) + ? statusWarning + : $"{dbWarning} {statusWarning}"; + } + var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim()) && !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim()); @@ -150,8 +204,8 @@ public sealed class AdminSystemController : ControllerBase DbExists: dbFile.Exists, DbSizeBytes: dbFile.Exists ? dbFile.Length : null, CompanyCount: companies, - JobCount: jobs.Count, - DeletedCount: jobs.Count(x => x.IsDeleted) + JobCount: jobCount, + DeletedCount: deletedCount ), Email: new EmailStatusDto( Enabled: _cfg.GetValue("Email:Enabled", false), diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b693888..931a90b 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -737,8 +737,39 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( } EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;"); EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;"); + EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;"); + EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;"); EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;"); EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;"); EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;"); EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;"); @@ -747,6 +778,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index b60f522..0ad91b1 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -231,13 +231,13 @@ export default function DashboardView() { > - + {t("dashboardHeroLabel")} - + {t("dashboardOverviewTitle")} - + {t("dashboardOverviewBody")} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 8e62f5d..58ea451 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -235,7 +235,7 @@ export const translations = { cropDialogZoom: "Zoom", cropDialogSave: "Save image", dashboardOverviewTitle: "Dashboard overview", - dashboardHeroLabel: "Jobbjakt Analytics", + dashboardHeroLabel: "Job search overview", dashboardResponseRate: "{rate}% response rate", dashboardMonthsShort: "{count} mo", dashboardAppliedCount: "{count} applied", @@ -243,7 +243,7 @@ export const translations = { dashboardResponseConversion: "{responses}/{total} response conversion", dashboardNoSourceData: "No source data yet.", dashboardCompanyJobsResponses: "{jobs} jobs · {responses} responses", - dashboardOverviewBody: "High-level application activity only. System health and pipeline diagnostics now live in the System page to avoid duplicated or conflicting status data.", + dashboardOverviewBody: "A quick view of your application activity, follow-ups, and momentum.", dashboardCustomize: "Customize dashboard", dashboardSummaryCards: "Summary cards", dashboardActivityChart: "Activity chart", @@ -1046,7 +1046,7 @@ export const translations = { cropDialogZoom: "Zoom", cropDialogSave: "Lagre bilde", dashboardOverviewTitle: "Dashboard-oversikt", - dashboardHeroLabel: "Jobbjakt Analyse", + dashboardHeroLabel: "Oversikt over jobbsøket", dashboardResponseRate: "{rate}% svarrate", dashboardMonthsShort: "{count} md", dashboardAppliedCount: "{count} søkt", @@ -1054,7 +1054,7 @@ export const translations = { dashboardResponseConversion: "{responses}/{total} svar-konvertering", dashboardNoSourceData: "Ingen kildedata ennå.", dashboardCompanyJobsResponses: "{jobs} jobber · {responses} svar", - dashboardOverviewBody: "Kun overordnet aktivitet for jobbsøking vises her. Systemhelse og pipelinediagnostikk ligger nå på Systemsiden for å unngå dupliserte eller motstridende statusdata.", + dashboardOverviewBody: "En rask oversikt over aktivitet, oppfølginger og fremdrift i jobbsøket ditt.", dashboardCustomize: "Tilpass dashboard", dashboardSummaryCards: "Oppsummeringskort", dashboardActivityChart: "Aktivitetsgraf", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index ebf5e43..b87a4d6 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -118,7 +118,16 @@ export default function ProfilePage() { setProfileCvText(r.data?.profileCvText ?? ""); try { const parsed = r.data?.profileCvStructureJson ? JSON.parse(r.data.profileCvStructureJson) : []; - setParsedCvSections(Array.isArray(parsed) ? parsed : []); + const normalized = Array.isArray(parsed) + ? parsed.map((section: any) => { + const content = typeof section?.content === "string" ? section.content : ""; + const name = typeof section?.name === "string" && section.name.trim() ? section.name : t("profileCvSectionSummary"); + const computedWordCount = content.trim() ? content.trim().split(/\s+/).length : 0; + const wordCount = Number.isFinite(Number(section?.wordCount)) ? Number(section.wordCount) : computedWordCount; + return { name, content, wordCount }; + }) + : []; + setParsedCvSections(normalized); } catch { setParsedCvSections([]); } @@ -363,7 +372,16 @@ export default function ProfilePage() { setParsingCvSections(true); try { const res = await api.post<{ sections?: ParsedCvSection[] }>("/profile-cv/parse", { text: profileCvText }); - setParsedCvSections(res.data?.sections ?? []); + const normalized = Array.isArray(res.data?.sections) + ? res.data.sections.map((section: any) => { + const content = typeof section?.content === "string" ? section.content : ""; + const name = typeof section?.name === "string" && section.name.trim() ? section.name : t("profileCvSectionSummary"); + const computedWordCount = content.trim() ? content.trim().split(/\s+/).length : 0; + const wordCount = Number.isFinite(Number(section?.wordCount)) ? Number(section.wordCount) : computedWordCount; + return { name, content, wordCount }; + }) + : []; + setParsedCvSections(normalized); toast(t("profileCvStructureParsed"), "success"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error"); @@ -377,15 +395,18 @@ export default function ProfilePage() { {parsedCvSections.length > 0 ? ( - {parsedCvSections.map((section) => ( + {parsedCvSections.map((section) => { + const safeContent = typeof section.content === "string" ? section.content : ""; + const safeWordCount = Number.isFinite(Number(section.wordCount)) ? Number(section.wordCount) : (safeContent.trim() ? safeContent.trim().split(/\s+/).length : 0); + return ( {section.name} - + - {section.content.slice(0, 280)}{section.content.length > 280 ? "…" : ""} + {safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""} - ))} + )})} ) : ( {t("profileCvStructureEmpty")}