Refresh dashboard, adopt MUI X, and improve AI follow-ups
This commit is contained in:
@@ -1358,10 +1358,10 @@ Candidate CV/profile:
|
|||||||
70);
|
70);
|
||||||
|
|
||||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
||||||
"Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.",
|
"Draft a concise recruiter message for this candidate and job. Mention the exact role and one or two concrete overlaps from the posting or candidate background. Keep it warm, direct, and under 120 words.",
|
||||||
jobContext,
|
jobContext,
|
||||||
120,
|
130,
|
||||||
45);
|
50);
|
||||||
|
|
||||||
var guidance = new CandidateFitChannelGuidanceDto(
|
var guidance = new CandidateFitChannelGuidanceDto(
|
||||||
Cv: mention.Take(4).ToList(),
|
Cv: mention.Take(4).ToList(),
|
||||||
@@ -1541,10 +1541,10 @@ Candidate master CV:
|
|||||||
70);
|
70);
|
||||||
|
|
||||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
||||||
"Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.",
|
"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile or job context. Keep it warm, direct, and concise.",
|
||||||
packageContext,
|
packageContext,
|
||||||
120,
|
140,
|
||||||
45);
|
55);
|
||||||
|
|
||||||
var keyPoints = SkillTagger.Detect(jobText)
|
var keyPoints = SkillTagger.Detect(jobText)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -1781,16 +1781,47 @@ Candidate master CV:
|
|||||||
var subject = $"Following up on {job.JobTitle} application";
|
var subject = $"Following up on {job.JobTitle} application";
|
||||||
var reference = lastMessage?.Subject ?? job.JobTitle;
|
var reference = lastMessage?.Subject ?? job.JobTitle;
|
||||||
var summary = job.ShortSummary;
|
var summary = job.ShortSummary;
|
||||||
var body = string.Join("\n\n", new[]
|
var appliedDate = job.DateApplied.ToString("MMMM d, yyyy");
|
||||||
|
var tagHighlights = SplitTags(job.Tags).Take(4).ToList();
|
||||||
|
var companyName = job.Company?.Name ?? "your team";
|
||||||
|
|
||||||
|
var aiContext = $@"Candidate name: {signerName}
|
||||||
|
Role: {job.JobTitle}
|
||||||
|
Company: {companyName}
|
||||||
|
Applied on: {appliedDate}
|
||||||
|
Reason for follow-up: {reason}
|
||||||
|
Last message subject: {lastMessage?.Subject ?? "None"}
|
||||||
|
Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")}
|
||||||
|
Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")}
|
||||||
|
Short fit summary: {summary ?? "None provided"}
|
||||||
|
Job description:
|
||||||
|
{job.TranslatedDescription ?? job.Description ?? "No job description available."}";
|
||||||
|
|
||||||
|
var aiBody = await _summarizer.SummarizeSectionAsync(
|
||||||
|
"Write a concise, professional follow-up email in first person. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Keep it specific, warm, and under 140 words. Return only the email body.",
|
||||||
|
aiContext,
|
||||||
|
180,
|
||||||
|
70);
|
||||||
|
|
||||||
|
var fallbackBody = string.Join("\n\n", new[]
|
||||||
{
|
{
|
||||||
greeting,
|
greeting,
|
||||||
$"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.",
|
$"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.",
|
||||||
!string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null,
|
!string.IsNullOrWhiteSpace(summary)
|
||||||
$"Context: {reason}",
|
? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}."
|
||||||
$"If helpful, I can also provide any additional information related to {reference}.",
|
: tagHighlights.Count > 0
|
||||||
|
? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience."
|
||||||
|
: null,
|
||||||
|
$"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.",
|
||||||
$"Thanks for your time,\n{signerName}"
|
$"Thanks for your time,\n{signerName}"
|
||||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
|
||||||
|
var body = !string.IsNullOrWhiteSpace(aiBody) ? aiBody.Trim() : fallbackBody;
|
||||||
|
if (!body.StartsWith("Hi", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
body = string.Join("\n\n", new[] { greeting, body, $"Thanks,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
|
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+169
@@ -13,6 +13,8 @@
|
|||||||
"@mui/icons-material": "^7.3.9",
|
"@mui/icons-material": "^7.3.9",
|
||||||
"@mui/lab": "^7.0.1-beta.23",
|
"@mui/lab": "^7.0.1-beta.23",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
|
"@mui/x-data-grid": "^8.28.0",
|
||||||
|
"@mui/x-date-pickers": "^8.27.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
@@ -3120,6 +3123,150 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-data-grid": {
|
||||||
|
"version": "8.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.28.0.tgz",
|
||||||
|
"integrity": "sha512-l1Kc9evMGLaK8Ap8t9r1Lb1zyWHvmA8adSYn8AlcSUaWIZS4Hpjq2uQkSHYfY0seXLYNemRzJBPW1uqRh1niTQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"@mui/x-internals": "8.26.0",
|
||||||
|
"@mui/x-virtualizer": "0.3.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers": {
|
||||||
|
"version": "8.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.27.2.tgz",
|
||||||
|
"integrity": "sha512-06LFkHFRXJ2O9DMXtWAA3kY0jpbL7XH8iqa8L5cBlN+8bRx/UVLKlZYlhGv06C88jF9kuZWY1bUgrv/EoY/2Ww==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"@mui/x-internals": "8.26.0",
|
||||||
|
"@types/react-transition-group": "^4.4.12",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||||
|
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||||
|
"dayjs": "^1.10.7",
|
||||||
|
"luxon": "^3.0.2",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||||
|
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns-jalali": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"dayjs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"luxon": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-hijri": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-jalaali": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-internals": {
|
||||||
|
"version": "8.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz",
|
||||||
|
"integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"reselect": "^5.1.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-virtualizer": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-6ugUh7UAhQYdgPgHLu181zqufh3Y8IqEU9Pe6Huzj0xkRi3NwMx/ZzvrHf2WazNOh2uLhQ5ZM2wFqDu3mxBWZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"@mui/x-internals": "8.26.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||||
"version": "5.1.1-v1",
|
"version": "5.1.1-v1",
|
||||||
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
||||||
@@ -6438,6 +6585,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -13609,6 +13765,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -15659,6 +15820,14 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"@mui/icons-material": "^7.3.9",
|
"@mui/icons-material": "^7.3.9",
|
||||||
"@mui/lab": "^7.0.1-beta.23",
|
"@mui/lab": "^7.0.1-beta.23",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
|
"@mui/x-data-grid": "^8.28.0",
|
||||||
|
"@mui/x-date-pickers": "^8.27.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
@@ -65,6 +67,17 @@ function getTodayIso() {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePickerDate(value?: string | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(+parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPickerIso(value: Date | null) {
|
||||||
|
if (!value || Number.isNaN(+value)) return "";
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyAttachmentBuckets(): AttachmentBuckets {
|
function emptyAttachmentBuckets(): AttachmentBuckets {
|
||||||
return {
|
return {
|
||||||
resume: [],
|
resume: [],
|
||||||
@@ -448,7 +461,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextField label={t("addJobModalDateApplied")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker
|
||||||
|
label={t("addJobModalDateApplied")}
|
||||||
|
value={parsePickerDate(dateApplied)}
|
||||||
|
onChange={(value) => setDateApplied(toPickerIso(value))}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
|
||||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((s) => (
|
||||||
@@ -462,7 +480,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
|
|
||||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||||
<TextField label={t("addJobModalDeadline")} type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker
|
||||||
|
label={t("addJobModalDeadline")}
|
||||||
|
value={parsePickerDate(deadline)}
|
||||||
|
onChange={(value) => setDeadline(toPickerIso(value))}
|
||||||
|
slotProps={{ textField: { fullWidth: true } }}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<TagsInput value={tags} onChange={setTags} />
|
<TagsInput value={tags} onChange={setTags} />
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
import TuneIcon from "@mui/icons-material/Tune";
|
import TuneIcon from "@mui/icons-material/Tune";
|
||||||
|
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
|
||||||
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { getUserKeyFromToken } from "../themePrefs";
|
import { getUserKeyFromToken } from "../themePrefs";
|
||||||
@@ -73,16 +80,49 @@ function clamp(n: number, a: number, b: number) {
|
|||||||
return Math.max(a, Math.min(b, n));
|
return Math.max(a, Math.min(b, n));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPath(values: number[], w: number, h: number) {
|
function buildLinePath(values: number[], width: number, height: number) {
|
||||||
if (values.length === 0) return "";
|
if (!values.length) return "";
|
||||||
const min = Math.min(...values);
|
const min = Math.min(...values);
|
||||||
const max = Math.max(...values);
|
const max = Math.max(...values);
|
||||||
const dx = w / Math.max(1, values.length - 1);
|
const step = width / Math.max(1, values.length - 1);
|
||||||
const norm = (v: number) => {
|
const yFor = (value: number) => {
|
||||||
const t = max === min ? 0.5 : (v - min) / (max - min);
|
const t = max === min ? 0.5 : (value - min) / (max - min);
|
||||||
return h - t * h;
|
return height - t * height;
|
||||||
};
|
};
|
||||||
return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
|
|
||||||
|
return values
|
||||||
|
.map((value, index) => `${index === 0 ? "M" : "L"} ${Math.round(index * step)} ${Math.round(yFor(value))}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSpark({ values, color }: { values: number[]; color: string }) {
|
||||||
|
const width = 180;
|
||||||
|
const height = 52;
|
||||||
|
const path = buildLinePath(values, width, height);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
<path d={path} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2.25,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
background: "background.paper",
|
||||||
|
boxShadow: "0 18px 50px rgba(15, 23, 42, 0.06)",
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardView() {
|
export default function DashboardView() {
|
||||||
@@ -111,22 +151,50 @@ export default function DashboardView() {
|
|||||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||||
}, [months]);
|
}, [months]);
|
||||||
|
|
||||||
const chartW = 860;
|
const appliedValues = analytics.map((x) => x.applied);
|
||||||
const chartH = 260;
|
const responseValues = analytics.map((x) => x.responses);
|
||||||
const appliedSeries = analytics.map((x) => x.applied);
|
const chartWidth = 860;
|
||||||
const responseSeries = analytics.map((x) => x.responses);
|
const chartHeight = 250;
|
||||||
const appliedPath = toPath(appliedSeries, chartW, chartH);
|
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
|
||||||
const responsePath = toPath(responseSeries, chartW, chartH);
|
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
|
||||||
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
||||||
const tagTotal = tags.reduce((acc, item) => acc + item.count, 0);
|
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0;
|
||||||
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
|
const topSource = overview?.responseRateBySource?.[0];
|
||||||
|
const missingCvCount = reminderJobs.filter((job) => !job.tailoredCvText).length;
|
||||||
|
|
||||||
const metricCards = [
|
const metricCards = [
|
||||||
{ label: t("dashboardActiveApplications"), value: stats?.active ?? "-", sub: t("dashboardCurrentlyInProgress") },
|
{
|
||||||
{ label: t("dashboardApplied30Days"), value: stats?.appliedLast30Days ?? "-", sub: t("dashboardNewApplications") },
|
label: t("dashboardActiveApplications"),
|
||||||
{ label: t("dashboardMedianFirstResponse"), value: overview?.medianDaysToFirstResponse ?? "-", sub: t("dashboardDaysUntilFirstReply") },
|
value: stats?.active ?? 0,
|
||||||
{ label: t("dashboardResponsesLogged"), value: overview?.totalResponses ?? 0, sub: t("dashboardAcrossActiveJobs") },
|
sub: t("dashboardCurrentlyInProgress"),
|
||||||
{ label: t("dashboardLowReadiness"), value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: t("dashboardMissingTailoredCv") },
|
icon: <TrendingUpIcon fontSize="small" />,
|
||||||
|
tone: theme.palette.primary.main,
|
||||||
|
spark: appliedValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("dashboardApplied30Days"),
|
||||||
|
value: stats?.appliedLast30Days ?? 0,
|
||||||
|
sub: t("dashboardNewApplications"),
|
||||||
|
icon: <AutoGraphIcon fontSize="small" />,
|
||||||
|
tone: theme.palette.success.main,
|
||||||
|
spark: appliedValues.slice(-6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("dashboardMedianFirstResponse"),
|
||||||
|
value: overview?.medianDaysToFirstResponse ?? "—",
|
||||||
|
sub: t("dashboardDaysUntilFirstReply"),
|
||||||
|
icon: <MailOutlineIcon fontSize="small" />,
|
||||||
|
tone: theme.palette.info.main,
|
||||||
|
spark: responseValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("dashboardResponsesLogged"),
|
||||||
|
value: overview?.totalResponses ?? 0,
|
||||||
|
sub: t("dashboardAcrossActiveJobs"),
|
||||||
|
icon: <BusinessOutlinedIcon fontSize="small" />,
|
||||||
|
tone: theme.palette.warning.main,
|
||||||
|
spark: responseValues.slice(-6),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const togglePref = (key: keyof Prefs) => {
|
const togglePref = (key: keyof Prefs) => {
|
||||||
@@ -135,27 +203,44 @@ export default function DashboardView() {
|
|||||||
savePrefs(next);
|
savePrefs(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatCard = ({ label, value, sub }: { label: string; value: React.ReactNode; sub: string }) => (
|
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||||
<Box sx={{ p: 2, minHeight: 118, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
|
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
||||||
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950 }}>{value}</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{sub}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
<SectionCard
|
||||||
<Box>
|
sx={{
|
||||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("dashboardOverviewTitle")}</Typography>
|
background: theme.palette.mode === "dark"
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
? `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.26)}, transparent 35%), linear-gradient(135deg, rgba(15,23,42,0.94), rgba(15,23,42,0.78))`
|
||||||
|
: `radial-gradient(circle at top left, ${alpha(theme.palette.primary.main, 0.18)}, transparent 35%), linear-gradient(135deg, rgba(255,255,255,0.98), rgba(248,250,252,0.96))`,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||||
|
<Box sx={{ maxWidth: 760 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "primary.main", fontWeight: 800 }}>
|
||||||
|
{t("dashboardHeroLabel")}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6 }}>
|
||||||
|
{t("dashboardOverviewTitle")}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
|
||||||
{t("dashboardOverviewBody")}
|
{t("dashboardOverviewBody")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2.25, flexWrap: "wrap" }}>
|
||||||
|
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: responseRate })} />
|
||||||
|
<Chip variant="outlined" label={`${missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||||
|
<Chip variant="outlined" label={topSource ? `${topSource.label}: ${topSource.rate}%` : t("dashboardResponseSources")} />
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{([6, 12, 24] as const).map((m) => (
|
{([6, 12, 24] as const).map((m) => (
|
||||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||||
{m} mo
|
{t("dashboardMonthsShort", { count: m })}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||||
@@ -177,137 +262,189 @@ export default function DashboardView() {
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{prefs.cards ? (
|
{prefs.cards ? (
|
||||||
<Paper sx={{ p: 0.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
{metricCards.map((card) => (
|
||||||
{metricCards.map((m, idx) => (
|
<SectionCard key={card.label}>
|
||||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2 }}>
|
||||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
<Box>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5 }}>{card.value}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>{card.sub}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ width: 42, height: 42, borderRadius: 3, display: "grid", placeItems: "center", backgroundColor: alpha(card.tone, 0.12), color: card.tone }}>
|
||||||
|
{card.icon}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
|
||||||
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
|
||||||
{prefs.activity ? (
|
{prefs.activity ? (
|
||||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
<SectionCard>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||||
|
<Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||||
|
<Chip size="small" label={t("dashboardAppliedCount", { count: totalApplied })} variant="outlined" />
|
||||||
|
<Chip size="small" label={t("dashboardResponsesCount", { count: totalResponses })} variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||||
<Box sx={{ minWidth: chartW }}>
|
<Box sx={{ minWidth: chartWidth }}>
|
||||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
<line
|
||||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
key={tick}
|
||||||
|
x1="0"
|
||||||
|
x2={chartWidth}
|
||||||
|
y1={Math.round(chartHeight * tick)}
|
||||||
|
y2={Math.round(chartHeight * tick)}
|
||||||
|
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||||
|
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||||
</svg>
|
</svg>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
||||||
</Box>
|
{analytics.map((point) => (
|
||||||
</Box>
|
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
|
||||||
</Paper>
|
{point.month.slice(5)}
|
||||||
) : null}
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
|
||||||
{prefs.funnel ? (
|
|
||||||
<Paper sx={{ p: 2.25 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
||||||
{(overview?.funnel ?? []).map((item) => (
|
|
||||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
|
||||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
|
||||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
|
||||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
|
||||||
</Box>
|
|
||||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>{t("dashboardResponseSources")}</Typography>
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
|
||||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
|
||||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
|
||||||
<Typography variant="body2">{item.label}</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</SectionCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SectionCard>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
|
||||||
|
<Stack spacing={1.2}>
|
||||||
|
{(overview?.funnel ?? []).map((item) => {
|
||||||
|
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
|
||||||
|
return (
|
||||||
|
<Box key={item.label}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={width}
|
||||||
|
sx={{
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||||
|
'& .MuiLinearProgress-bar': {
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{topSource ? `${topSource.rate}%` : "—"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
|
{topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||||
{prefs.companies ? (
|
{prefs.companies ? (
|
||||||
<Paper sx={{ p: 2.25 }}>
|
<SectionCard>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
<Stack spacing={1.25}>
|
||||||
{(overview?.topCompanies ?? []).map((item) => (
|
{(overview?.topCompanies ?? []).map((item, index) => (
|
||||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
|
||||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Stack>
|
||||||
</Paper>
|
</SectionCard>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
|
||||||
|
|
||||||
{prefs.skills ? (
|
{prefs.skills ? (
|
||||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
<SectionCard>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography> : (
|
{tags.length === 0 ? (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
|
||||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
) : (
|
||||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
<Stack spacing={1.15}>
|
||||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
{tags.slice(0, 8).map((tag, index) => {
|
||||||
{(() => {
|
const max = Math.max(...tags.map((item) => item.count), 1);
|
||||||
const r = 52;
|
const width = (tag.count / max) * 100;
|
||||||
const circ = 2 * Math.PI * r;
|
return (
|
||||||
let offset = 0;
|
<Box key={tag.tag}>
|
||||||
return tags.map((tItem, i) => {
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
|
||||||
const len = circ * (tagTotal ? tItem.count / tagTotal : 0);
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{tag.tag}</Typography>
|
||||||
const el = <circle key={tItem.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
|
||||||
offset += len;
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
|
||||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
|
||||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>{t("dashboardSkillTags")}</text>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||||
{tags.slice(0, 8).map((tItem, i) => <Box key={tItem.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{tItem.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{tItem.count}</Typography></Box>)}
|
<Box sx={{ width: `${width}%`, height: "100%", borderRadius: 999, bgcolor: tagColors[index % tagColors.length] }} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
<Box>
|
<Typography variant="h6" sx={{ fontWeight: 950, mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
{!tagTrends || tagTrends.series.length === 0 ? (
|
||||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography> : (
|
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
) : (
|
||||||
{tagTrends.series.map((series, idx) => (
|
<Stack spacing={1.1}>
|
||||||
|
{tagTrends.series.map((series, index) => (
|
||||||
<Box key={series.tag}>
|
<Box key={series.tag}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 0)} total</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||||
{series.counts.map((count, i) => (
|
{series.counts.map((count, i) => (
|
||||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
|
<Box
|
||||||
|
key={`${series.tag}-${i}`}
|
||||||
|
sx={{
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 1.25,
|
||||||
|
backgroundColor: count > 0 ? alpha(tagColors[index % tagColors.length], 0.22 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.05),
|
||||||
|
}}
|
||||||
|
title={`${tagTrends.months[i]}: ${count}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
</Stack>
|
||||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</SectionCard>
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Chip,
|
Chip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { Company, JobApplication } from "../types";
|
import { Company, JobApplication } from "../types";
|
||||||
@@ -40,6 +41,17 @@ function toDateInputValue(isoLike?: string): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePickerDate(value?: string | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(+parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPickerIso(value: Date | null): string {
|
||||||
|
if (!value || Number.isNaN(+value)) return "";
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function parseTags(raw: any): string[] {
|
function parseTags(raw: any): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
|
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
|
||||||
@@ -172,7 +184,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||||
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
|
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
|
||||||
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||||
<TextField label={t("editJobAppliedOn")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||||
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
|
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -183,11 +195,11 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
|
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField label={t("editJobStatusChangedOn")} type="date" value={statusChangedAt} onChange={(e) => setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive")} />
|
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
||||||
<TextField label={t("editJobReplyReceivedOn")} type="date" disabled={!responseReceived} value={responseDate} onChange={(e) => setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||||
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||||
<TextField label={t("editJobFollowUpOn")} type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -196,7 +208,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||||
<TextField label={t("editJobDeadline")} type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
|
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
|
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||||
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
|
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
|
||||||
|
|||||||
@@ -197,6 +197,14 @@ export const translations = {
|
|||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
cropDialogSave: "Save image",
|
cropDialogSave: "Save image",
|
||||||
dashboardOverviewTitle: "Dashboard overview",
|
dashboardOverviewTitle: "Dashboard overview",
|
||||||
|
dashboardHeroLabel: "Jobbjakt Analytics",
|
||||||
|
dashboardResponseRate: "{rate}% response rate",
|
||||||
|
dashboardMonthsShort: "{count} mo",
|
||||||
|
dashboardAppliedCount: "{count} applied",
|
||||||
|
dashboardResponsesCount: "{count} responses",
|
||||||
|
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: "High-level application activity only. System health and pipeline diagnostics now live in the System page to avoid duplicated or conflicting status data.",
|
||||||
dashboardCustomize: "Customize dashboard",
|
dashboardCustomize: "Customize dashboard",
|
||||||
dashboardSummaryCards: "Summary cards",
|
dashboardSummaryCards: "Summary cards",
|
||||||
@@ -910,6 +918,14 @@ export const translations = {
|
|||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
cropDialogSave: "Lagre bilde",
|
cropDialogSave: "Lagre bilde",
|
||||||
dashboardOverviewTitle: "Dashboard-oversikt",
|
dashboardOverviewTitle: "Dashboard-oversikt",
|
||||||
|
dashboardHeroLabel: "Jobbjakt Analyse",
|
||||||
|
dashboardResponseRate: "{rate}% svarrate",
|
||||||
|
dashboardMonthsShort: "{count} md",
|
||||||
|
dashboardAppliedCount: "{count} søkt",
|
||||||
|
dashboardResponsesCount: "{count} svar",
|
||||||
|
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: "Kun overordnet aktivitet for jobbsøking vises her. Systemhelse og pipelinediagnostikk ligger nå på Systemsiden for å unngå dupliserte eller motstridende statusdata.",
|
||||||
dashboardCustomize: "Tilpass dashboard",
|
dashboardCustomize: "Tilpass dashboard",
|
||||||
dashboardSummaryCards: "Oppsummeringskort",
|
dashboardSummaryCards: "Oppsummeringskort",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
@@ -10,9 +12,11 @@ const root = ReactDOM.createRoot(
|
|||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
</LocalizationProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -58,7 +54,16 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
const canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]);
|
const canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]);
|
||||||
|
|
||||||
const setAdminRole = async (u: UserDto, isAdmin: boolean) => {
|
const rows = useMemo(() => users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email || "",
|
||||||
|
userName: u.userName || "",
|
||||||
|
roles: u.roles || [],
|
||||||
|
emailConfirmed: u.emailConfirmed,
|
||||||
|
raw: u,
|
||||||
|
})), [users]);
|
||||||
|
|
||||||
|
const setAdminRole = useCallback(async (u: UserDto, isAdmin: boolean) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
|
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
|
||||||
toast(t("adminUsersRolesUpdated"), "success");
|
toast(t("adminUsersRolesUpdated"), "success");
|
||||||
@@ -67,9 +72,9 @@ export default function AdminUsersPage() {
|
|||||||
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
|
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
|
||||||
toast(String(msg), "error");
|
toast(String(msg), "error");
|
||||||
}
|
}
|
||||||
};
|
}, [t, toast]);
|
||||||
|
|
||||||
const sendReset = async (u: UserDto) => {
|
const sendReset = useCallback(async (u: UserDto) => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/users/${u.id}/send-password-reset`);
|
await api.post(`/users/${u.id}/send-password-reset`);
|
||||||
toast(t("adminUsersResetSent"), "success");
|
toast(t("adminUsersResetSent"), "success");
|
||||||
@@ -77,9 +82,9 @@ export default function AdminUsersPage() {
|
|||||||
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
|
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
|
||||||
toast(String(msg), "error");
|
toast(String(msg), "error");
|
||||||
}
|
}
|
||||||
};
|
}, [t, toast]);
|
||||||
|
|
||||||
const remove = async (u: UserDto) => {
|
const remove = useCallback(async (u: UserDto) => {
|
||||||
const name = u.userName || u.email || u.id;
|
const name = u.userName || u.email || u.id;
|
||||||
if (!(await confirmAction(t("adminUsersDeleteConfirmNamed", { name }), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
if (!(await confirmAction(t("adminUsersDeleteConfirmNamed", { name }), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +94,60 @@ export default function AdminUsersPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
toast(t("adminUsersDeleteFailed"), "error");
|
toast(t("adminUsersDeleteFailed"), "error");
|
||||||
}
|
}
|
||||||
};
|
}, [confirmAction, t, toast]);
|
||||||
|
|
||||||
|
const columns = useMemo<GridColDef[]>(() => [
|
||||||
|
{ field: "email", headerName: t("profileEmail"), flex: 1.2, minWidth: 220 },
|
||||||
|
{ field: "userName", headerName: t("profileUsername"), flex: 1, minWidth: 180 },
|
||||||
|
{
|
||||||
|
field: "roles",
|
||||||
|
headerName: t("adminUsersRolesLabel"),
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 180,
|
||||||
|
sortable: false,
|
||||||
|
renderCell: (params) => {
|
||||||
|
const roles = params.row.roles as string[];
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", py: 0.5 }}>
|
||||||
|
{roles.length ? roles.map((role) => <Chip key={role} size="small" label={role} variant="outlined" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>—</Typography>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "emailConfirmed",
|
||||||
|
headerName: t("adminUsersConfirmed"),
|
||||||
|
width: 130,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Chip size="small" label={params.value ? t("yes") : t("noWord")} color={params.value ? "success" : "default"} variant={params.value ? "filled" : "outlined"} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: t("adminUsersActions"),
|
||||||
|
minWidth: 300,
|
||||||
|
flex: 1.4,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
renderCell: (params) => {
|
||||||
|
const user = params.row.raw as UserDto;
|
||||||
|
const isAdmin = (user.roles || []).includes("Admin");
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", py: 0.5 }}>
|
||||||
|
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)}>
|
||||||
|
{t("adminUsersAdmin")}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => void sendReset(user)}>
|
||||||
|
{t("adminUsersSendReset")}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" color="error" variant="outlined" onClick={() => void remove(user)}>
|
||||||
|
{t("adminUsersDelete")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [remove, sendReset, setAdminRole, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
@@ -128,53 +186,33 @@ export default function AdminUsersPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<TableContainer sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}>
|
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||||
<Table size="small">
|
<DataGrid
|
||||||
<TableHead>
|
autoHeight
|
||||||
<TableRow>
|
rows={rows}
|
||||||
<TableCell>{t("profileEmail")}</TableCell>
|
columns={columns}
|
||||||
<TableCell>{t("profileUsername")}</TableCell>
|
disableRowSelectionOnClick
|
||||||
<TableCell>{t("adminUsersRolesLabel")}</TableCell>
|
loading={loading}
|
||||||
<TableCell>{t("adminUsersConfirmed")}</TableCell>
|
pageSizeOptions={[5, 10, 25]}
|
||||||
<TableCell align="right">{t("adminUsersActions")}</TableCell>
|
initialState={{
|
||||||
</TableRow>
|
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
||||||
</TableHead>
|
sorting: { sortModel: [{ field: "email", sort: "asc" }] },
|
||||||
<TableBody>
|
}}
|
||||||
{users.map((u) => {
|
sx={{
|
||||||
const isAdmin = (u.roles || []).includes("Admin");
|
border: 0,
|
||||||
return (
|
'& .MuiDataGrid-columnHeaders': {
|
||||||
<TableRow key={u.id} hover>
|
backgroundColor: 'action.hover',
|
||||||
<TableCell sx={{ fontWeight: 850 }}>{u.email || ""}</TableCell>
|
fontWeight: 800,
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.userName || ""}</TableCell>
|
},
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.roles?.length ? u.roles.join(", ") : "-"}</TableCell>
|
'& .MuiDataGrid-cell': {
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? t("yes") : t("noWord")}</TableCell>
|
alignItems: 'center',
|
||||||
<TableCell align="right">
|
},
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
}}
|
||||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(u, !isAdmin)}>
|
/>
|
||||||
{t("adminUsersAdmin")}
|
{!loading && rows.length === 0 ? (
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => void sendReset(u)}>
|
|
||||||
{t("adminUsersSendReset")}
|
|
||||||
</Button>
|
|
||||||
<Button size="small" color="error" variant="outlined" onClick={() => void remove(u)}>
|
|
||||||
{t("adminUsersDelete")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!loading && users.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5}>
|
|
||||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TableBody>
|
</Paper>
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user