Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
@@ -25,7 +25,7 @@ EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=CHANGE_ME_GMAIL_ADDRESS
EMAIL_SMTP_PASSWORD=CHANGE_ME_GOOGLE_APP_PASSWORD
EMAIL_FROM=CHANGE_ME_GMAIL_ADDRESS
EMAIL_FROM_NAME=Job Tracker
EMAIL_FROM_NAME=Jobbjakt
EMAIL_SMTP_ENABLE_SSL=true
EMAIL_SMTP_TIMEOUT_MS=15000
@@ -57,6 +57,7 @@ public sealed class AuthController : ControllerBase
string? LastName,
string? DisplayName,
string? ProfileCvText,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText);
@@ -172,6 +173,7 @@ public sealed class AuthController : ControllerBase
LastName: User.FindFirstValue("family_name"),
DisplayName: User.FindFirstValue("name"),
ProfileCvText: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
}
@@ -277,6 +279,70 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
if (file is null || file.Length == 0)
{
return BadRequest("Image file is required.");
}
if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
if (file.Length > 5_000_000)
{
return BadRequest("Avatar image is too large.");
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var base64 = Convert.ToBase64String(bytes);
user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}";
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
}
[HttpDelete("avatar")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> DeleteAvatar()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.AvatarImageDataUrl = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
@@ -374,6 +440,7 @@ public sealed class AuthController : ControllerBase
LastName: user.LastName,
DisplayName: user.DisplayName,
ProfileCvText: user.ProfileCvText,
AvatarImageDataUrl: user.AvatarImageDataUrl,
Roles: roles,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,301 +14,7 @@ namespace JobTrackerApi.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UploadDate")
.HasColumnType("TEXT");
b.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("Deadline")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DescriptionLanguage")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("ShortSummary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.Property<string>("Tags")
.HasColumnType("TEXT");
b.Property<string>("TranslatedDescription")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OldValue")
.HasColumnType("TEXT");
b.Property<string>("NewValue")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<DateTime>("At")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("JobEvents");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Attachments")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Events")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Attachments");
b.Navigation("Events");
b.Navigation("Messages");
});
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
#pragma warning restore 612, 618
}
}
+59
View File
@@ -347,6 +347,7 @@ CREATE TABLE IF NOT EXISTS `AspNetUsers` (
`LastName` longtext NULL,
`DisplayName` longtext NULL,
`ProfileCvText` longtext NULL,
`AvatarImageDataUrl` longtext NULL,
`GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL,
`GoogleLinkedAt` datetime(6) NULL,
@@ -498,6 +499,8 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"FirstName" TEXT NULL,
"LastName" TEXT NULL,
"DisplayName" TEXT NULL,
"ProfileCvText" TEXT NULL,
"AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL
@@ -570,6 +573,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
@@ -680,6 +684,61 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
using var conn = new MySqlConnection(cs);
conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlColumnExists(MySqlConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@column", column);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIndexExists(MySqlConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@index", indexName);
return cmd.ExecuteScalar() is not null;
}
static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl)
{
using var existsCmd = c.CreateCommand();
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
existsCmd.Parameters.AddWithValue("@schema", c.Database);
existsCmd.Parameters.AddWithValue("@table", table);
if (existsCmd.ExecuteScalar() is null) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
{
{
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -19,13 +19,13 @@
"Auth": {
"Require": true,
"AllowRegistration": false,
"JwtKey": "Y00VuqZehhsMiNa8elch7q7FOlPm5ncugKJtMOpFn3P2xNtrZVfvGxVP2bKbnzL6rI08/H6vZGNBYh1dHh71/g==",
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "dj@cesnimda.co.uk",
"AdminPassword": "Leethacks12",
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com"
"AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID"
},
"App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk"
@@ -37,7 +37,7 @@
"SmtpUser": "CHANGE_ME_GMAIL_ADDRESS",
"SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD",
"From": "CHANGE_ME_GMAIL_ADDRESS",
"FromName": "Job Tracker",
"FromName": "Jobbjakt",
"SmtpEnableSsl": true,
"SmtpTimeoutMs": 15000
}
+1
View File
@@ -8,6 +8,7 @@ public sealed class ApplicationUser : IdentityUser
public string? LastName { get; set; }
public string? DisplayName { get; set; }
public string? ProfileCvText { get; set; }
public string? AvatarImageDataUrl { get; set; }
public string? GoogleSubject { get; set; }
public string? GoogleEmail { get; set; }
public DateTimeOffset? GoogleLinkedAt { get; set; }
+9 -1
View File
@@ -35,7 +35,15 @@ to:
This keeps secrets outside the uploaded repo checkout so they are not wiped by CI deploys.
### Example production `.env`
### Frontend API base URL
The production frontend already proxies `/api` to the backend container via Nginx.
Recommended default:
- leave `REACT_APP_API_BASE_URL` unset/empty in production
Only set `REACT_APP_API_BASE_URL` if the UI must call a different external API origin on purpose.
## Example production `.env`
```env
DATABASE_PROVIDER=mariadb
JOBTRACKER_CONNECTION_STRING=server=mariadb;port=3306;database=jobtracker;user=jobtracker;password=REPLACE_ME
+3 -33
View File
@@ -8,44 +8,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="theme-color" content="#0b1224" />
<meta
name="description"
content="Jobbjakt — track and manage job applications"
/>
<meta name="theme-color" content="#15803d" />
<meta name="description" content="Jobbjakt — track and manage job applications" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>JobTrack</title>
<title>Jobbjakt</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
un build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 130 KiB

+49 -40
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
import { Box, Button, CssBaseline, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { CssVarsProvider } from "@mui/material/styles";
@@ -38,27 +38,39 @@ import AdminAuditPage from "./pages/AdminAuditPage";
import AdminUsersPage from "./pages/AdminUsersPage";
import AdminSystemPage from "./pages/AdminSystemPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import NotFoundPage from "./pages/NotFoundPage";
import RouteErrorPage from "./pages/RouteErrorPage";
import { api } from "./api";
import { clearAuthToken, getAuthToken } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
type AuthConfig = { requireAuth: boolean };
type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; roles?: string[] };
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
avatarImageDataUrl?: string;
roles?: string[];
};
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"];
if (path.startsWith("/jobs")) return ["Home", t("jobApplications")];
if (path.startsWith("/reminders")) return ["Home", t("reminders")];
if (path.startsWith("/kanban")) return ["Home", t("kanbanBoard")];
if (path.startsWith("/companies")) return ["Home", t("companies")];
if (path.startsWith("/trash")) return ["Home", t("trash")];
if (path.startsWith("/settings")) return ["Home", t("settings")];
if (path.startsWith("/profile")) return ["Home", "Account", "Profile"];
if (path.startsWith("/admin/audit")) return ["Home", "Admin", "Audit"];
if (path.startsWith("/admin/users")) return ["Home", "Admin", "Users"];
if (path.startsWith("/admin/system")) return ["Home", "Admin", "System"];
return ["Home"];
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
return [t("home")];
}
function titleFor(path: string, t: (k: any) => string): string {
@@ -69,17 +81,17 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/companies")) return t("companies");
if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return "Profile";
if (path.startsWith("/admin/audit")) return "Audit log";
if (path.startsWith("/admin/users")) return "Users";
if (path.startsWith("/admin/system")) return "System status";
if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
return t("appTitle");
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { language, setLanguage, t } = useI18n();
const { t } = useI18n();
const [addOpen, setAddOpen] = useState(false);
const [quickOpen, setQuickOpen] = useState(false);
@@ -126,31 +138,28 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: "Manage" },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: "Manage" },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: "Manage" },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: "Manage" },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: "Manage" },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: "Manage" },
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
];
const navBottom: NavItem[] = [
{ to: "/admin/audit", label: "Audit log", icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/admin/users", label: "Users", icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/admin/system", label: "System", icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
{ to: "/profile", label: "Profile", icon: <AccountCircleIcon fontSize="small" />, section: "Account" },
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: "Account" },
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" />, section: t("account") },
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account") },
];
const rightActions = (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>Quick Search</Button>
<ToggleButtonGroup size="small" exclusive value={language} onChange={(_, v) => v && setLanguage(v)}>
<ToggleButton value="en">EN</ToggleButton>
<ToggleButton value="no">NO</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
</Box>
);
@@ -166,7 +175,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
drawerOpen={mobileDrawerOpen}
onToggleDrawer={setMobileDrawerOpen}
onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }}
user={{ email: me?.email, userName: me?.userName, roleLabel: isAdmin ? "Super Admin" : "User" }}
user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }}
notificationsCount={notifCount}
onOpenNotifications={() => navigate("/reminders")}
onOpenSettings={() => navigate("/settings")}
@@ -187,7 +196,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="*" element={<Navigate to="/jobs" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppShell>
@@ -231,9 +240,9 @@ export default function App() {
});
const router = useMemo(() => createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/reset-password", element: <ResetPasswordPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
return (
+21
View File
@@ -2,6 +2,27 @@ import axios from "axios";
import { getAuthToken } from "./auth";
import { clearAuthToken } from "./auth";
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
const data = error?.response?.data;
if (typeof data === "string" && data.trim()) return data.trim();
if (typeof data?.message === "string" && data.message.trim()) return data.message.trim();
if (Array.isArray(data?.errors)) {
const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim());
if (first) return first;
}
if (data?.errors && typeof data.errors === "object") {
for (const value of Object.values(data.errors)) {
if (Array.isArray(value)) {
const first = value.find((item: unknown) => typeof item === "string" && item.trim());
if (first) return first;
}
if (typeof value === "string" && value.trim()) return value.trim();
}
}
if (typeof error?.message === "string" && error.message.trim()) return error.message.trim();
return fallback;
}
const envBaseUrl = process.env.REACT_APP_API_BASE_URL;
const defaultBaseUrl =
window.location.hostname === "localhost"
+207 -84
View File
@@ -5,12 +5,13 @@ import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControlLabel,
IconButton,
List,
ListItem,
ListItemText,
@@ -19,7 +20,10 @@ import {
Typography,
} from "@mui/material";
import { api } from "../api";
import CloseIcon from "@mui/icons-material/Close";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
import { api, getApiErrorMessage } from "../api";
import { Company, JobImportResult } from "../types";
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
import { useToast } from "../toast";
@@ -47,19 +51,43 @@ type DuplicateCheckResult = {
matches: DuplicateCandidate[];
};
type CreatedJobResponse = {
id?: number;
};
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
function getTodayIso() {
return new Date().toISOString().slice(0, 10);
}
function emptyAttachmentBuckets(): AttachmentBuckets {
return {
resume: [],
coverLetter: [],
portfolio: [],
other: [],
};
}
function normalizeLanguage(value?: string | null) {
const raw = (value || "").trim().toLowerCase();
if (!raw) return "";
if (["en", "eng", "english"].includes(raw)) return "en";
if (["no", "nb", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "no";
return raw;
}
export default function AddJobModal({ open, onClose, onCreated }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [saveAndAddAnother, setSaveAndAddAnother] = useState(false);
const [duplicateCheck, setDuplicateCheck] = useState<DuplicateCheckResult | null>(null);
const { companies: cachedCompanies } = useCompanies();
@@ -75,8 +103,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
const [location, setLocation] = useState("");
const [salary, setSalary] = useState("");
const [nextAction, setNextAction] = useState("");
const [followUpAt, setFollowUpAt] = useState("");
const [jobUrl, setJobUrl] = useState("");
const [deadline, setDeadline] = useState("");
@@ -85,14 +111,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const [descriptionLanguage, setDescriptionLanguage] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [coverLetter, setCoverLetter] = useState("");
const [hasResume, setHasResume] = useState(false);
const [hasCoverLetter, setHasCoverLetter] = useState(false);
const [hasPortfolio, setHasPortfolio] = useState(false);
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
useEffect(() => {
setCompanies(cachedCompanies);
@@ -108,8 +128,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setStatus("Applied");
setLocation("");
setSalary("");
setNextAction("");
setFollowUpAt("");
setJobUrl("");
setDeadline("");
setDescription("");
@@ -117,11 +135,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setDescriptionLanguage("");
setTags([]);
setNotes("");
setCoverLetter("");
setHasResume(false);
setHasCoverLetter(false);
setHasPortfolio(false);
setHasOtherAttachment(false);
setAttachments(emptyAttachmentBuckets());
setDuplicateCheck(null);
};
@@ -133,6 +147,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const selectedCompanyId = company?.id ?? matchingCompany?.id ?? 0;
const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany;
const preferredLanguage = normalizeLanguage(language);
const sourceLanguage = normalizeLanguage(descriptionLanguage);
const shouldShowTranslatedDescription = Boolean(sourceLanguage && preferredLanguage && sourceLanguage !== preferredLanguage);
const attachmentCount = Object.values(attachments).reduce((sum, files) => sum + files.length, 0);
useEffect(() => {
if (!open) return;
@@ -180,8 +198,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setNewCompanyLocation("");
setNewCompanySource("");
return res.data;
} catch {
toast("Failed to create company.", "error");
} catch (error: any) {
toast(getApiErrorMessage(error, t("addJobModalFailedCreateCompany")), "error");
return null;
}
};
@@ -189,7 +207,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const importFromUrl = async () => {
if (importing) return;
if (!jobUrl.trim()) {
toast("Paste a job URL first.", "warning");
toast(t("addJobModalPasteUrlFirst"), "warning");
return;
}
@@ -197,7 +215,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
try {
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
const r = res.data;
if (!r?.success) throw new Error(r?.error || "Import failed");
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
if (r.title) setJobTitle(r.title);
if (r.location) setLocation(r.location);
@@ -217,15 +235,28 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setTags(r.tags || []);
setDeadline(r.deadline ? r.deadline.slice(0, 10) : "");
toast("Imported.", "success");
toast(t("addJobModalImported"), "success");
} catch (e: any) {
toast(e?.message || "Import failed.", "error");
toast(e?.message || t("addJobModalImportFailed"), "error");
} finally {
setImporting(false);
}
};
const createJob = async () => {
const uploadAttachments = async (jobId: number) => {
const files = Object.values(attachments).flat();
if (!files.length) return;
const data = new FormData();
files.forEach((file) => data.append("files", file));
data.append("jobId", String(jobId));
await api.post("/attachments", data, {
headers: { "Content-Type": "multipart/form-data" },
});
};
const createJob = async (addAnother = false) => {
if (saving) return;
setSaving(true);
@@ -235,41 +266,53 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
selectedCompany = await createCompany();
}
if (!selectedCompany) {
toast("Select or create a company.", "warning");
toast(t("addJobModalSelectCompany"), "warning");
return;
}
await api.post("/jobapplications", {
const response = await api.post<CreatedJobResponse>("/jobapplications", {
jobTitle,
companyId: selectedCompany.id,
status,
location,
salary,
nextAction,
followUpAt: followUpAt || null,
nextAction: null,
followUpAt: null,
jobUrl,
description: description || null,
translatedDescription: translatedDescription || null,
translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null,
descriptionLanguage: descriptionLanguage || null,
tags: tags.length ? JSON.stringify(tags) : null,
deadline: deadline || null,
notes,
coverLetterText: coverLetter,
coverLetterText: null,
dateApplied,
hasResume,
hasCoverLetter,
hasPortfolio,
hasOtherAttachment,
hasResume: attachments.resume.length > 0,
hasCoverLetter: attachments.coverLetter.length > 0,
hasPortfolio: attachments.portfolio.length > 0,
hasOtherAttachment: attachments.other.length > 0,
});
if (response.data?.id && attachmentCount > 0) {
try {
await uploadAttachments(response.data.id);
toast(t("addJobModalJobAndFilesAdded"), "success");
} catch {
toast(t("addJobModalJobCreatedUploadFailed"), "warning");
}
} else if (attachmentCount > 0) {
toast(t("addJobModalJobCreatedFilesNotAttached"), "warning");
} else {
toast(t("addJobModalJobAdded"), "success");
}
onCreated();
toast("Job added.", "success");
resetForm();
if (!saveAndAddAnother) {
if (!addAnother) {
onClose();
}
} catch {
toast("Failed to add job.", "error");
toast(t("addJobModalFailedAddJob"), "error");
} finally {
setSaving(false);
}
@@ -277,12 +320,64 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0;
const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => {
setAttachments((prev) => ({
...prev,
[bucket]: files ? Array.from(files) : [],
}));
};
const statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
const map = {
Applied: t("statusApplied"),
Waiting: t("statusWaiting"),
Interview: t("statusInterview"),
Offer: t("statusOffer"),
Rejected: t("statusRejected"),
Ghosted: t("statusGhosted"),
} as const;
return map[value];
};
const filesLabel = (files: File[]) => {
if (files.length === 0) return t("addJobModalNoFilesSelected");
if (files.length === 1) return files[0].name;
return t("addJobModalFilesSelected", { count: files.length });
};
const uploadField = (
bucket: AttachmentBucketKey,
label: string,
helperText: string,
) => (
<Box sx={{ p: 1.5, borderRadius: 2, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>{label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{helperText}</Typography>
</Box>
<Button component="label" variant="outlined" size="small" startIcon={<UploadFileOutlinedIcon />}>
{t("addJobModalChooseFiles")}
<input hidden type="file" multiple accept={ACCEPTED_DOCUMENT_TYPES} onChange={(e) => setFilesForBucket(bucket, e.target.files)} />
</Button>
</Box>
<Typography variant="caption" sx={{ display: "block", mt: 1, color: "text.secondary" }}>
{filesLabel(attachments[bucket])}
</Typography>
</Box>
);
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{t("addJob")}</DialogTitle>
<DialogContent>
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>
Company
<DialogTitle sx={{ pr: 6 }}>
{t("addJob")}
<IconButton aria-label={t("close")} onClick={onClose} sx={{ position: "absolute", right: 12, top: 12 }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Typography variant="overline" sx={{ display: "block", mt: 0.5 }}>
{t("addJobModalCompanySection")}
</Typography>
<Autocomplete<Company, false, false, true>
@@ -312,12 +407,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
/>
{showNewCompanyFields ? (
<Box sx={{ mt: 1, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<TextField label="Company location" value={newCompanyLocation} onChange={(e) => setNewCompanyLocation(e.target.value)} />
<TextField label="Company source" value={newCompanySource} onChange={(e) => setNewCompanySource(e.target.value)} />
<Box sx={{ mt: 1, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<TextField label={t("addJobModalCompanyLocation")} value={newCompanyLocation} onChange={(e) => setNewCompanyLocation(e.target.value)} />
<TextField label={t("addJobModalCompanySource")} value={newCompanySource} onChange={(e) => setNewCompanySource(e.target.value)} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Button variant="outlined" onClick={() => void createCompany()}>
Create "{normalizedCompanyName}"
{t("addJobModalCreateCompany", { name: normalizedCompanyName })}
</Button>
</Box>
</Box>
@@ -325,7 +420,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
{duplicateCheck?.hasDuplicates ? (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>Possible duplicates found</Typography>
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>{t("addJobModalPossibleDuplicates")}</Typography>
<List dense sx={{ py: 0 }}>
{duplicateCheck.matches.map((match) => (
<ListItem key={match.id} sx={{ px: 0 }}>
@@ -342,68 +437,96 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
<Divider sx={{ my: 2 }} />
<Typography variant="overline" sx={{ display: "block" }}>
Job application
{t("addJobModalJobApplicationSection")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
<TextField label="Job URL" value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
{importing ? "Importing..." : "Import from URL"}
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
</Button>
</Box>
<TextField label="Date applied" type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label={t("addJobModalDateApplied")} type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField select label="Status" 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) => (
<MenuItem key={s} value={s}>
{s}
{statusLabel(s)}
</MenuItem>
))}
</TextField>
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
<TextField label="Follow up" type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label="Deadline" type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
<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 }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<TagsInput value={tags} onChange={setTags} />
</Box>
<TextField label="Description (original)" multiline rows={6} value={description} onChange={(e) => setDescription(e.target.value)} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Translated description" multiline rows={6} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Description language (optional)" value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Notes" multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<TextField label="Cover letter" multiline rows={6} value={coverLetter} onChange={(e) => setCoverLetter(e.target.value)} helperText={`${coverLetter.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<TextField
label={t("addJobModalDescriptionOriginal")}
multiline
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
helperText={`${description.length} characters`}
sx={{ gridColumn: "1 / -1" }}
/>
{shouldShowTranslatedDescription ? (
<TextField
label={t("addJobModalTranslatedDescription", { language: preferredLanguage.toUpperCase() })}
multiline
rows={6}
value={translatedDescription}
onChange={(e) => setTranslatedDescription(e.target.value)}
helperText={`${translatedDescription.length} characters`}
sx={{ gridColumn: "1 / -1" }}
/>
) : null}
<TextField
label={t("addJobModalDescriptionLanguage")}
value={descriptionLanguage}
onChange={(e) => setDescriptionLanguage(e.target.value)}
helperText={shouldShowTranslatedDescription ? t("addJobModalTranslatedShown", { language: preferredLanguage.toUpperCase() }) : t("addJobModalTranslatedHidden")}
sx={{ gridColumn: "1 / -1" }}
/>
<TextField label={t("addJobModalNotes")} multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>Attachments checklist</Typography>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" />
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label="Cover letter" />
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label="Portfolio" />
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label="Other" />
<Typography variant="overline" sx={{ display: "block", mb: 1 }}>{t("addJobModalDocuments")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
{uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp"))}
{uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp"))}
{uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp"))}
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1, flexWrap: "wrap" }}>
<FormControlLabel control={<Checkbox checked={saveAndAddAnother} onChange={(e) => setSaveAndAddAnother(e.target.checked)} />} label="Save and add another" />
<Box sx={{ display: "flex", gap: 1 }}>
<Button variant="outlined" onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={() => void createJob()} disabled={saving || !canSave}>
{saving ? "Adding..." : saveAndAddAnother ? "Save and continue" : "Add job"}
</Button>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.25 }}>
<Chip size="small" variant="outlined" label={attachmentCount === 1 ? t("addJobModalFileReady", { count: attachmentCount }) : t("addJobModalFilesReady", { count: attachmentCount })} />
<Chip size="small" variant="outlined" label={t("addJobModalPreferredFiles")} />
<Chip size="small" variant="outlined" label={t("addJobModalTextImageAllowed")} />
</Box>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, justifyContent: "space-between", flexWrap: "wrap", gap: 1.5 }}>
<Button variant="outlined" onClick={onClose}>{t("close")}</Button>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => void createJob(true)} disabled={saving || !canSave}>
{saving ? t("rulesSaving") : t("createAndAddAnother")}
</Button>
<Button variant="contained" onClick={() => void createJob(false)} disabled={saving || !canSave}>
{saving ? t("rulesSaving") : t("createJob")}
</Button>
</Box>
</DialogActions>
</Dialog>
);
}
@@ -19,13 +19,15 @@ import {
IconButton,
} from "@mui/material";
import { api } from "../api";
import { api, getApiErrorMessage } from "../api";
import { Company } from "../types";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
export default function CompaniesTable() {
const { toast } = useToast();
const { t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[]>([]);
@@ -40,8 +42,8 @@ export default function CompaniesTable() {
const [nextContactAt, setNextContactAt] = useState("");
useEffect(() => {
api.get<Company[]>("/companies").then((r) => setCompanies(r.data));
}, []);
api.get<Company[]>("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error"));
}, [t, toast]);
useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -83,11 +85,11 @@ export default function CompaniesTable() {
});
setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
toast("Company updated.", "success");
toast(t("companiesUpdated"), "success");
setEditOpen(false);
setEditing(null);
} catch {
toast("Failed to update company.", "error");
} catch (error) {
toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error");
}
};
@@ -96,12 +98,12 @@ export default function CompaniesTable() {
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Location</TableCell>
<TableCell>Source</TableCell>
<TableCell>Pipeline</TableCell>
<TableCell>Recruiter</TableCell>
<TableCell>Next Contact</TableCell>
<TableCell>{t("companiesName")}</TableCell>
<TableCell>{t("companiesLocation")}</TableCell>
<TableCell>{t("companiesSource")}</TableCell>
<TableCell>{t("companiesPipeline")}</TableCell>
<TableCell>{t("companiesRecruiter")}</TableCell>
<TableCell>{t("companiesNextContact")}</TableCell>
<TableCell width={1} align="right" />
</TableRow>
</TableHead>
@@ -128,7 +130,7 @@ export default function CompaniesTable() {
<TableRow>
<TableCell colSpan={7}>
<Typography sx={{ py: 2, textAlign: "center" }}>
No companies yet.
{t("companiesEmpty")}
</Typography>
</TableCell>
</TableRow>
@@ -137,57 +139,57 @@ export default function CompaniesTable() {
</Table>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Edit Company</DialogTitle>
<DialogTitle>{t("companiesEdit")}</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
<TextField
label="Name"
label={t("companiesName")}
value={editing?.name ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
sx={{ gridColumn: "1 / -1" }}
/>
<TextField
label="Location"
label={t("companiesLocation")}
value={editing?.location ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
/>
<TextField
label="Source"
label={t("companiesSource")}
value={editing?.source ?? ""}
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
/>
<TextField
label="Pipeline stage"
label={t("companiesPipelineStage")}
value={pipelineStage}
onChange={(e) => setPipelineStage(e.target.value)}
/>
<TextField
label="Recruiter name"
label={t("companiesRecruiterName")}
value={recruiterName}
onChange={(e) => setRecruiterName(e.target.value)}
/>
<TextField
label="Recruiter email"
label={t("companiesRecruiterEmail")}
value={recruiterEmail}
onChange={(e) => setRecruiterEmail(e.target.value)}
/>
<TextField
label="Recruiter LinkedIn"
label={t("companiesRecruiterLinkedIn")}
value={recruiterLinkedIn}
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
sx={{ gridColumn: "1 / -1" }}
/>
<TextField
label="Last contacted"
label={t("companiesLastContacted")}
type="date"
value={lastContactedAt}
onChange={(e) => setLastContactedAt(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Next contact"
label={t("companiesNextContactField")}
type="date"
value={nextContactAt}
onChange={(e) => setNextContactAt(e.target.value)}
@@ -196,9 +198,9 @@ export default function CompaniesTable() {
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={() => setEditOpen(false)}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>
Save
{t("save")}
</Button>
</DialogActions>
</Dialog>
@@ -0,0 +1,224 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Slider,
Typography,
} from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
const CROPPER_SIZE = 280;
const OUTPUT_SIZE = 512;
type DragState = {
startX: number;
startY: number;
originX: number;
originY: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export default function CropImageDialog({
open,
file,
onClose,
onSave,
}: {
open: boolean;
file: File | null;
onClose: () => void;
onSave: (blob: Blob) => Promise<void> | void;
}) {
const { t } = useI18n();
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [saving, setSaving] = useState(false);
const dragRef = useRef<DragState | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
if (!file || !open) {
setImageUrl(null);
setImageSize(null);
setZoom(1);
setPosition({ x: 0, y: 0 });
return;
}
const url = URL.createObjectURL(file);
setImageUrl(url);
setImageSize(null);
setZoom(1);
setPosition({ x: 0, y: 0 });
return () => URL.revokeObjectURL(url);
}, [file, open]);
const rendered = useMemo(() => {
if (!imageSize) return null;
const scale = Math.max(CROPPER_SIZE / imageSize.width, CROPPER_SIZE / imageSize.height) * zoom;
const width = imageSize.width * scale;
const height = imageSize.height * scale;
return { scale, width, height };
}, [imageSize, zoom]);
useEffect(() => {
if (!rendered) return;
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
setPosition((prev) => ({
x: clamp(prev.x, minX, 0),
y: clamp(prev.y, minY, 0),
}));
}, [rendered]);
const beginDrag = (clientX: number, clientY: number) => {
dragRef.current = {
startX: clientX,
startY: clientY,
originX: position.x,
originY: position.y,
};
};
const moveDrag = useCallback((clientX: number, clientY: number) => {
if (!dragRef.current || !rendered) return;
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
const nextX = dragRef.current.originX + (clientX - dragRef.current.startX);
const nextY = dragRef.current.originY + (clientY - dragRef.current.startY);
setPosition({ x: clamp(nextX, minX, 0), y: clamp(nextY, minY, 0) });
}, [rendered]);
useEffect(() => {
const onMouseMove = (event: MouseEvent) => moveDrag(event.clientX, event.clientY);
const onTouchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (touch) moveDrag(touch.clientX, touch.clientY);
};
const stop = () => {
dragRef.current = null;
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", stop);
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", stop);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", stop);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", stop);
};
}, [moveDrag]);
const exportCrop = async () => {
if (!file || !rendered) return;
const image = imgRef.current;
if (!image) return;
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const cropX = -position.x / rendered.scale;
const cropY = -position.y / rendered.scale;
const cropWidth = CROPPER_SIZE / rendered.scale;
const cropHeight = CROPPER_SIZE / rendered.scale;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
setSaving(true);
try {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png", 0.95));
if (!blob) return;
await onSave(blob);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle>{t("cropDialogTitle")}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("cropDialogBody")}
</Typography>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<Box
sx={{
width: CROPPER_SIZE,
height: CROPPER_SIZE,
borderRadius: "50%",
overflow: "hidden",
position: "relative",
bgcolor: "grey.100",
border: "1px solid",
borderColor: "divider",
cursor: "grab",
userSelect: "none",
touchAction: "none",
boxShadow: "inset 0 0 0 999px rgba(0,0,0,0.03)",
}}
onMouseDown={(e) => beginDrag(e.clientX, e.clientY)}
onTouchStart={(e) => {
const touch = e.touches[0];
if (touch) beginDrag(touch.clientX, touch.clientY);
}}
>
{imageUrl ? (
<img
ref={imgRef}
src={imageUrl}
alt="Crop preview"
onLoad={(e) => {
const target = e.currentTarget;
setImageSize({ width: target.naturalWidth, height: target.naturalHeight });
}}
draggable={false}
style={{
position: "absolute",
left: position.x,
top: position.y,
width: rendered?.width ?? "auto",
height: rendered?.height ?? "auto",
maxWidth: "none",
pointerEvents: "none",
}}
/>
) : null}
</Box>
</Box>
<Box sx={{ px: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>{t("cancel")}</Button>
<Button variant="contained" onClick={() => void exportCrop()} disabled={!file || saving || !rendered}>
{saving ? t("profileUploading") : t("cropDialogSave")}
</Button>
</DialogActions>
</Dialog>
);
}
+46 -173
View File
@@ -1,16 +1,12 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Box,
Button,
ButtonGroup,
Checkbox,
Menu,
MenuItem,
Paper,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
@@ -18,6 +14,7 @@ import TuneIcon from "@mui/icons-material/Tune";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
interface JobStats {
total: number;
@@ -36,24 +33,6 @@ type ReminderJob = {
type AnalyticsPoint = { month: string; applied: number; responses: number };
type TagPoint = { tag: string; count: number };
type SummarizerMetrics = {
healthy: boolean;
model?: string | null;
healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
lastProbeSuccessAt?: string | null;
lastProbeFailureAt?: string | null;
probeFailures: number;
requests: number;
cacheHits: number;
cacheMisses: number;
failures: number;
averageLatencyMs?: number | null;
lastSuccessAt?: string | null;
lastFailureAt?: string | null;
lastError?: string | null;
};
type OverviewAnalytics = {
funnel: { label: string; count: number }[];
responseRateBySource: { label: string; total: number; responses: number; rate: number }[];
@@ -106,32 +85,15 @@ function toPath(values: number[], w: number, h: number) {
return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
}
function formatRelative(ts?: string | null) {
if (!ts) return "Never";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return "Unknown";
const mins = Math.round((Date.now() - d.getTime()) / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
export default function DashboardView() {
const theme = useTheme();
const { t } = useI18n();
const [stats, setStats] = useState<JobStats | null>(null);
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
const [tab, setTab] = useState(0);
const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset");
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7));
const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7));
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
const [tags, setTags] = useState<TagPoint[]>([]);
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
@@ -143,34 +105,12 @@ export default function DashboardView() {
}, []);
useEffect(() => {
const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months };
const params = { months };
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
}, [months, rangeMode, appliedCustom]);
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
}, [months]);
useEffect(() => {
if (tab !== 2) return;
let cancelled = false;
const load = async () => {
try {
const res = await api.get<SummarizerMetrics>("/jobapplications/summarizer-metrics");
if (!cancelled) setSummarizerMetrics(res.data);
} catch {
if (!cancelled) setSummarizerMetrics(null);
}
};
void load();
const id = window.setInterval(() => void load(), 30000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [tab]);
const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]);
const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0;
const chartW = 860;
const chartH = 260;
const appliedSeries = analytics.map((x) => x.applied);
@@ -182,11 +122,11 @@ export default function DashboardView() {
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
const metricCards = [
{ label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" },
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
{ label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" },
{ label: t("dashboardActiveApplications"), value: stats?.active ?? "-", sub: t("dashboardCurrentlyInProgress") },
{ label: t("dashboardApplied30Days"), value: stats?.appliedLast30Days ?? "-", sub: t("dashboardNewApplications") },
{ label: t("dashboardMedianFirstResponse"), value: overview?.medianDaysToFirstResponse ?? "-", sub: t("dashboardDaysUntilFirstReply") },
{ label: t("dashboardResponsesLogged"), value: overview?.totalResponses ?? 0, sub: t("dashboardAcrossActiveJobs") },
{ label: t("dashboardLowReadiness"), value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: t("dashboardMissingTailoredCv") },
];
const togglePref = (key: keyof Prefs) => {
@@ -205,24 +145,29 @@ export default function DashboardView() {
return (
<Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Overview" />
<Tab label="Pipeline" />
<Tab label="Summarizer" />
</Tabs>
{tab !== 2 ? (
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("dashboardOverviewTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardOverviewBody")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{([6, 12, 24] as const).map((m) => (
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
{m} mo
</Button>
))}
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
Customize dashboard
{t("dashboardCustomize")}
</Button>
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
{[
["cards", "Summary cards"],
["activity", "Activity chart"],
["funnel", "Conversion funnel"],
["companies", "Top companies"],
["skills", "Skills insights"],
["cards", t("dashboardSummaryCards")],
["activity", t("dashboardActivityChart")],
["funnel", t("dashboardConversionFunnel")],
["companies", t("dashboardTopCompanies")],
["skills", t("dashboardSkillsInsights")],
].map(([key, label]) => (
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
<Checkbox checked={prefs[key as keyof Prefs]} />
@@ -231,10 +176,8 @@ export default function DashboardView() {
))}
</Menu>
</Box>
) : null}
</Box>
{tab === 0 ? (
<>
{prefs.cards ? (
<Paper sx={{ p: 0.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
@@ -249,27 +192,8 @@ export default function DashboardView() {
{prefs.activity ? (
<Paper sx={{ mt: 2, p: 2.25 }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950 }}>Application activity</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Monthly applications versus responses.</Typography>
</Box>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<ButtonGroup size="small">
{([6, 12, 24] as const).map((m) => (
<Button key={m} variant={rangeMode === "preset" && months === m ? "contained" : "outlined"} onClick={() => { setRangeMode("preset"); setMonths(m); }}>{m} mo</Button>
))}
<Button variant={rangeMode === "custom" ? "contained" : "outlined"} onClick={() => { setRangeMode("custom"); setAppliedCustom({ from: fromMonth, to: toMonth }); }}>Custom</Button>
</ButtonGroup>
{rangeMode === "custom" ? (
<>
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} />
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} />
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>Apply</Button>
</>
) : null}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
<Box sx={{ mt: 2, overflowX: "auto" }}>
<Box sx={{ minWidth: chartW }}>
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
@@ -286,7 +210,7 @@ export default function DashboardView() {
<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 }}>Conversion funnel</Typography>
<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" }}>
@@ -298,7 +222,7 @@ export default function DashboardView() {
</Box>
))}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>Response sources</Typography>
<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 }}>
@@ -312,7 +236,7 @@ export default function DashboardView() {
{prefs.companies ? (
<Paper sx={{ p: 2.25 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top companies by activity</Typography>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{(overview?.topCompanies ?? []).map((item) => (
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
@@ -330,8 +254,8 @@ export default function DashboardView() {
<Paper sx={{ mt: 2, p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top skills</Typography>
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography> : (
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography> : (
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<svg width="132" height="132" viewBox="0 0 132 132">
@@ -340,27 +264,27 @@ export default function DashboardView() {
const r = 52;
const circ = 2 * Math.PI * r;
let offset = 0;
return tags.map((t, i) => {
const len = circ * (tagTotal ? t.count / tagTotal : 0);
const el = <circle key={t.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)" />;
return tags.map((tItem, i) => {
const len = circ * (tagTotal ? tItem.count / tagTotal : 0);
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)" />;
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)}>skill tags</text>
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>{t("dashboardSkillTags")}</text>
</svg>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{tags.slice(0, 8).map((t, i) => <Box key={t.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 }}>{t.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{t.count}</Typography></Box>)}
{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>
</Box>
)}
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Skill trends</Typography>
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tag trend data yet.</Typography> : (
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography> : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{tagTrends.series.map((series, idx) => (
<Box key={series.tag}>
@@ -384,57 +308,6 @@ export default function DashboardView() {
</Box>
</Paper>
) : null}
</>
) : null}
{tab === 1 ? (
<Paper sx={{ p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Status breakdown</Typography>
{statusRows.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No data yet.</Typography> : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{statusRows.map(([status, value]) => {
const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main;
const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0;
return <Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}><Typography sx={{ fontWeight: 850 }}>{status}</Typography><Box sx={{ height: 10, borderRadius: 999, background: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}><Box sx={{ width: `${w}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})` }} /></Box><Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography></Box>;
})}
</Box>
)}
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Response rate by source</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{(overview?.responseRateBySource ?? []).map((item) => (
<Box key={item.label} sx={{ p: 1.25, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.responses} responses from {item.total} jobs</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mt: 0.5 }}>{item.rate}%</Typography>
</Box>
))}
</Box>
</Box>
</Box>
</Paper>
) : null}
{tab === 2 ? (
<Paper sx={{ p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
</Box>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
<Typography variant="body2"><strong>Requests:</strong> {summarizerMetrics?.requests ?? 0}</Typography>
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
<Typography variant="body2"><strong>Probe failures:</strong> {summarizerMetrics?.probeFailures ?? 0}</Typography>
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
</Paper>
</Paper>
) : null}
</Box>
);
}
@@ -22,6 +22,7 @@ import { Company, JobApplication } from "../types";
import { useToast } from "../toast";
import { useCompanies } from "../hooks/useCompanies";
import TagsInput from "./TagsInput";
import { useI18n } from "../i18n/I18nProvider";
interface Props {
open: boolean;
@@ -53,6 +54,7 @@ function parseTags(raw: any): string[] {
export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const { companies } = useCompanies();
@@ -144,7 +146,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
dateApplied: dateApplied || null,
jobUrl: jobUrl.trim() || null,
});
toast("Saved.", "success");
toast(t("save"), "success");
onSaved();
onClose();
} catch {
@@ -156,7 +158,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>Edit job</DialogTitle>
<DialogTitle>{t("editJobTitle")}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
@@ -221,8 +223,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>Save</Button>
<Button onClick={onClose}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
</DialogActions>
</Dialog>
);
@@ -5,6 +5,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
declare global {
interface Window {
@@ -46,6 +47,7 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
const { t } = useI18n();
const [token, setToken] = useState<string | null>(() => getAuthToken());
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
@@ -81,20 +83,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
if (cancelled) return;
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
toast(t("googleSignedIn"), "success");
onSignedIn?.();
} catch {
if (cancelled) return;
clearAuthToken();
setToken(null);
toast("This Google account is not linked yet. Sign in locally first to bind it.", "info");
toast(t("googleNotLinkedYet"), "info");
}
};
void exchange();
return () => {
cancelled = true;
};
}, [token, isRawGoogleToken, onSignedIn, toast]);
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
useEffect(() => {
const host = hostRef.current;
@@ -102,9 +104,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
host.replaceChildren();
if (!shouldRenderButton) {
return;
}
if (!shouldRenderButton) return;
let active = true;
void loadGoogleScript()
@@ -120,17 +120,17 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
try {
if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
toast(res.data?.email ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success");
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
await refreshMe();
} else {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Google authentication failed.";
const msg = e?.response?.data || e?.message || t("googleAuthFailed");
toast(String(msg), "error");
} finally {
setWorking(false);
@@ -145,46 +145,48 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
text: me?.provider === "local" ? "continue_with" : "signin_with",
});
})
.catch(() => toast("Google auth script failed to load.", "error"));
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
return () => {
active = false;
host.replaceChildren();
};
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Google account
{t("googleAccountTitle")}
</Typography>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
{t("googleSetupHint")}
</Typography>
)}
{clientId && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.googleLink?.linked ? "Linked" : "Available to link"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={`Linked ${new Date(me.googleLink.linkedAt).toLocaleDateString()}`} /> : null}
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
</Box>
{!token ? (
<Typography sx={{ color: "text.secondary" }}>
Sign in with a Google account that has already been linked to your Job Tracker user.
{t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
{me.googleLink?.linked
? `Linked to ${me.googleLink.email || "your Google account"}.`
: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."}
? t("googleLinkedTo", { email: me.googleLink.email || t("googleLinkedToYourAccount") })
: t("googleBindHint")}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
Exchange your Google sign-in for a normal Job Tracker session.
{t("googleExchangeHint")}
</Typography>
)}
@@ -198,10 +200,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
clearAuthToken();
setToken(null);
setMe(null);
toast("Signed out.", "info");
toast(t("signedOut"), "info");
}}
>
Sign out
{t("signOut")}
</Button>
) : null}
@@ -213,22 +215,22 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
onClick={async () => {
try {
await api.delete("/auth/google/link");
toast("Google account unlinked.", "info");
toast(t("googleUnlinked"), "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
const msg = e?.response?.data || e?.message || t("googleUnlinkFailed");
toast(String(msg), "error");
}
}}
>
Unlink Google
{t("unlinkGoogle")}
</Button>
) : null}
</Box>
{token && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}.
{t("signedInAs", { name: signedInName })}
</Typography>
) : null}
</Box>
@@ -5,6 +5,7 @@ import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { Company, JobApplication } from "../types";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type ImportJob = Omit<JobApplication, "id" | "company"> & {
company: Pick<Company, "name" | "location" | "source">;
@@ -32,7 +33,6 @@ function parseCsv(text: string): string[][] {
cur = "";
};
const pushRow = () => {
// ignore trailing empty rows
if (row.length === 1 && row[0] === "" && rows.length > 0) {
row = [];
return;
@@ -91,15 +91,13 @@ function parseCsv(text: string): string[][] {
function parseDateDMY(s: string): string | null {
const v = (s || "").trim();
if (!v) return null;
// expects dd/MM/yyyy
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v);
if (!m) return null;
const dd = Number(m[1]);
const mm = Number(m[2]);
const yyyy = Number(m[3]);
if (!yyyy || mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
const iso = `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
return iso;
return `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
}
function csvToImportJobs(csvText: string): ImportJob[] {
@@ -122,7 +120,6 @@ function csvToImportJobs(csvText: string): ImportJob[] {
const iInterviewDate = idx("Interview Date");
const out: ImportJob[] = [];
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const get = (i: number) => (i >= 0 ? (row[i] ?? "").trim() : "");
@@ -132,9 +129,7 @@ function csvToImportJobs(csvText: string): ImportJob[] {
if (!jobTitle && !companyName) continue;
const rawStatus = get(iStatus);
const status =
rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied";
const status = rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied";
const dateApplied = parseDateDMY(get(iDateApplied)) ?? new Date().toISOString().slice(0, 10);
const responseReceived = /^yes$/i.test(get(iResp));
const responseDate = parseDateDMY(get(iRespDate)) ?? undefined;
@@ -179,6 +174,7 @@ function csvToImportJobs(csvText: string): ImportJob[] {
export default function ImportExportJobs() {
const { toast } = useToast();
const { t } = useI18n();
const [importing, setImporting] = useState(false);
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
@@ -188,17 +184,11 @@ export default function ImportExportJobs() {
const url = `/export/jobs?format=${format}&includeDeleted=false`;
const res = await api.get(url, { responseType: "blob" });
if (format === "json") {
const text = await res.data.text();
downloadText(`job-tracker-export-${stamp}.json`, text, "application/json");
} else {
const text = await res.data.text();
downloadText(`job-tracker-export-${stamp}.csv`, text, "text/csv");
}
toast(`Exported jobs (${format.toUpperCase()}).`, "success");
downloadText(`job-tracker-export-${stamp}.${format}`, text, format === "json" ? "application/json" : "text/csv");
toast(t("exportedJobs", { format: format.toUpperCase() }), "success");
} catch {
toast("Export failed.", "error");
toast(t("exportFailed"), "error");
}
};
@@ -215,7 +205,6 @@ export default function ImportExportJobs() {
for (const j of parsed) {
const companyRes = await api.post<Company>("/companies", j.company);
const company = companyRes.data;
await api.post("/jobapplications", {
jobTitle: j.jobTitle,
companyId: company.id,
@@ -233,9 +222,9 @@ export default function ImportExportJobs() {
}
setLastImportCount(created);
toast(`Imported ${created} jobs.`, "success");
toast(t("importedJobs", { count: created }), "success");
} catch {
toast("Import failed (expecting exported JSON array).", "error");
toast(t("importFailedJson"), "error");
} finally {
setImporting(false);
}
@@ -247,63 +236,41 @@ export default function ImportExportJobs() {
const text = await file.text();
const jobs = csvToImportJobs(text);
const stamp = new Date().toISOString().slice(0, 10);
downloadText(
`job-tracker-import-${stamp}.json`,
JSON.stringify(jobs, null, 2),
"application/json",
);
toast(`Converted ${jobs.length} rows to import JSON.`, "success");
downloadText(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json");
toast(t("convertedRows", { count: jobs.length }), "success");
} catch {
toast("CSV conversion failed.", "error");
toast(t("csvConversionFailed"), "error");
}
};
const helper = useMemo(
() =>
"Import expects the JSON exported by this app (an array of job objects with embedded company).",
[],
);
const helper = useMemo(() => t("importExportBody"), [t]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Import / Export
{t("importExportTitle")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{helper}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Button variant="outlined" onClick={() => void exportAll("json")}>
Export JSON
</Button>
<Button variant="outlined" onClick={() => void exportAll("csv")}>
Export CSV
</Button>
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
<Button variant="contained" component="label" disabled={importing}>
{importing ? "Importing..." : "Import JSON"}
<input
type="file"
accept="application/json"
hidden
onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)}
/>
{importing ? t("profileUploading") : t("importJson")}
<input type="file" accept="application/json" hidden onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)} />
</Button>
<Button variant="outlined" component="label">
Convert CSV to Import JSON
<input
type="file"
accept=".csv,text/csv"
hidden
onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)}
/>
{t("convertCsvToImportJson")}
<input type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
</Button>
{lastImportCount !== null && (
<Typography sx={{ ml: 1, color: "text.secondary" }}>
Last import: {lastImportCount}
{t("lastImport", { count: lastImportCount })}
</Typography>
)}
</Box>
@@ -26,6 +26,7 @@ import { useDialogActions } from "../dialogs";
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
import JobFlowBar from "./JobFlowBar";
import { useI18n } from "../i18n/I18nProvider";
type FollowUpDraft = {
subject: string;
@@ -70,6 +71,7 @@ function copyLines(items: string[]) {
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [job, setJob] = useState<JobApplication | null>(null);
@@ -152,7 +154,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
}
})();
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication");
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
const translatedDescriptionText = job?.translatedDescription?.trim() || "";
@@ -166,7 +168,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<DialogTitle sx={{ pb: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Job workspace</Typography>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOpen")}</Typography>
<Typography variant="h6">{title}</Typography>
</Box>
{job && <Chip label={job.status} color={statusChipColor(job.status)} size="small" />}
@@ -179,14 +181,14 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
</Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
<Tab label="Overview" />
<Tab label={t("jobTableOverview")} />
<Tab label="Correspondence" />
<Tab label="Attachments" />
<Tab label="Tailored CV" />
<Tab label="Follow-up draft" />
<Tab label={t("jobTableFollowUp")} />
<Tab label="Candidate fit" />
<Tab label="Interview prep" />
<Tab label="Readiness" />
<Tab label={t("jobTableReadiness")} />
{isAdmin ? <Tab label="History" /> : null}
</Tabs>
+53 -51
View File
@@ -46,6 +46,7 @@ import EditJobDialog from "./EditJobDialog";
import { useToast } from "../toast";
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
interface JobApplication {
id: number;
@@ -125,6 +126,7 @@ function statusTone(status: string): string {
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
const theme = useTheme();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
const navigate = useNavigate();
@@ -218,39 +220,39 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
if (jobsToDelete.length === 0) return false;
if (jobsToDelete.length === 1) {
const job = jobsToDelete[0];
return confirmAction(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`, { title: "Move job to trash", confirmLabel: "Move", destructive: true });
return confirmAction(t("jobTableMoveOneConfirm", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }), { title: t("jobTableMoveToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true });
}
return confirmAction(`Move ${jobsToDelete.length} selected jobs to trash?`, { title: "Move jobs to trash", confirmLabel: "Move", destructive: true });
return confirmAction(t("jobTableMoveManyConfirm", { count: jobsToDelete.length }), { title: t("jobTableMoveJobsToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true });
};
const softDelete = async (job: JobApplication) => {
if (!(await confirmDelete([job]))) return;
try {
await api.delete(`/jobapplications/${job.id}`);
toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } });
toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } });
setReloadToken((t) => t + 1);
} catch {
toast("Failed to delete job.", "error");
toast(t("jobTableDeleteFailed"), "error");
}
};
const restore = async (id: number) => {
try {
await api.post(`/jobapplications/${id}/restore`);
toast("Job restored.", "success");
toast(t("jobTableRestored"), "success");
setReloadToken((t) => t + 1);
} catch {
toast("Failed to restore job.", "error");
toast(t("jobTableRestoreFailed"), "error");
}
};
const setStatusQuick = async (id: number, status: string) => {
try {
await api.patch(`/jobapplications/${id}/status`, { status });
toast(`Status set to ${status}.`, "success");
toast(t("jobTableStatusSet", { status }), "success");
setReloadToken((t) => t + 1);
} catch {
toast("Failed to update status.", "error");
toast(t("jobTableStatusUpdateFailed"), "error");
}
};
@@ -264,11 +266,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
if (action === "restore") return api.post(`/jobapplications/${id}/restore`);
return api.patch(`/jobapplications/${id}/status`, { status: value });
}));
toast(`Updated ${selectedIds.length} jobs.`, "success");
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
setReloadToken((t) => t + 1);
setSelectedIds([]);
} catch {
toast("Bulk action failed.", "error");
toast(t("jobTableBulkActionFailed"), "error");
}
};
@@ -282,55 +284,55 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
<Box>
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
<TextField label="Search" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder="Title, company, notes, messages" size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
<TextField label={t("jobTableSearch")} value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
<FormControl sx={{ minWidth: 160 }} size="small">
<InputLabel>Status</InputLabel>
<Select value={statusFilter} label="Status" onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
{["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
<InputLabel>{t("jobTableStatus")}</InputLabel>
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel>Company</InputLabel>
<Select value={companyFilterId} label="Company" onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
<MenuItem value="All">All</MenuItem>
<InputLabel>{t("jobTableCompany")}</InputLabel>
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</Select>
</FormControl>
<TextField label="Location" value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
<TextField label={t("jobTableLocation")} value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
{mode === "jobs" ? (
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Readiness</InputLabel>
<Select value={readinessFilter} label="Readiness" onChange={(e) => setReadinessFilter(e.target.value as any)}>
<MenuItem value="all">All readiness</MenuItem>
<MenuItem value="needs-work">Needs work</MenuItem>
<MenuItem value="interview">Interview stage</MenuItem>
<InputLabel>{t("jobTableReadiness")}</InputLabel>
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
</Select>
</FormControl>
) : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null}
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
<Tooltip title="Columns"><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
</Box>
</Box>
{selectedIds.length > 0 ? (
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800 }}>{selectedIds.length} selected</Typography>
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>Restore selected</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>Delete selected</Button>}
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>{t("jobTableDeleteSelected")}</Button>}
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
</Box>
</Paper>
) : null}
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
{([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => (
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
<MenuItem key={key} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
<Checkbox checked={columns[key]} />
{label}
@@ -344,13 +346,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TableRow>
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell width={1} />
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>Company</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>Role</TableSortLabel></TableCell>
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>Status</TableSortLabel></TableCell> : null}
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>Date Applied</TableSortLabel></TableCell> : null}
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>Days</TableSortLabel></TableCell> : null}
{columns.jobUrl ? <TableCell>Job URL</TableCell> : null}
<TableCell align="right">Actions</TableCell>
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
<TableCell align="right">{t("jobTableActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -367,21 +369,21 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<span>{job.jobTitle}</span>
{job.needsFollowUp ? <Chip size="small" label="Follow up" title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label="CV missing" color="warning" variant="outlined" /> : null}
{job.tailoredCvText ? <Chip size="small" label="CV ready" color="success" variant="outlined" /> : null}
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" /> : null}
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" /> : null}
</Box>
</TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Link</a> : ""}</TableCell> : null}
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
<Tooltip title="Edit"><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Quick status"><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Open"><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title="Restore"><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</TableCell>
</TableRow>
@@ -389,11 +391,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box><Typography variant="overline">Location</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">Salary</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">Job URL</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Open listing</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Skills</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>No tags</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Overview</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || "No summary yet."}</Typography></Box>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
</Box>
</Collapse>
</TableCell>
@@ -401,7 +403,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>No jobs found.</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
</TableBody>
</Table>
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
@@ -410,7 +412,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} onClose={() => setDetailsJobId(null)} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s}</MenuItem>)}
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
</Menu>
</Box>
);
+8 -31
View File
@@ -16,6 +16,7 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { api } from "../api";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
type Status = (typeof STATUSES)[number];
@@ -36,6 +37,7 @@ function toneColor(theme: any, status: Status | "Other"): string {
export default function KanbanBoard() {
const theme = useTheme();
const { t } = useI18n();
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [dragJobId, setDragJobId] = useState<number | null>(null);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
@@ -64,9 +66,7 @@ export default function KanbanBoard() {
if (!dragJobId) return;
setDragJobId(null);
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
setJobs((prev) =>
prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)),
);
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
};
const setStatus = async (id: number, status: Status) => {
@@ -74,9 +74,7 @@ export default function KanbanBoard() {
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
};
const currentMenuStatus = menuJobId == null
? null
: normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
return (
<Box sx={{ mt: 2 }}>
@@ -84,14 +82,7 @@ export default function KanbanBoard() {
Drag cards between columns to update status.
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gap: 2,
alignItems: "start",
}}
>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
@@ -178,26 +169,12 @@ export default function KanbanBoard() {
})}
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => {
setMenuAnchor(null);
setMenuJobId(null);
}}
>
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
.filter((s) => s !== currentMenuStatus)
.map((s) => (
<MenuItem
key={s}
onClick={() => {
if (menuJobId) void setStatus(menuJobId, s);
setMenuAnchor(null);
setMenuJobId(null);
}}
>
Set {s}
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
{t("jobTableSetStatus", { status: s })}
</MenuItem>
))}
</Menu>
@@ -14,6 +14,7 @@ import {
import SearchIcon from "@mui/icons-material/Search";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type CommandItem = {
id: string;
@@ -41,6 +42,7 @@ interface Props {
}
export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAddJob }: Props) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
@@ -82,31 +84,31 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
const commands = useMemo<CommandItem[]>(() => {
const base: CommandItem[] = [
{ id: "go-dashboard", label: "Go to dashboard", hint: "Analytics overview", action: () => onNavigate("/dashboard") },
{ id: "go-jobs", label: "Go to jobs", hint: "Main applications table", action: () => onNavigate("/jobs") },
{ id: "go-reminders", label: "Go to reminders", hint: "Follow-up queue", action: () => onNavigate("/reminders") },
{ id: "go-companies", label: "Go to companies", hint: "CRM and source tracking", action: () => onNavigate("/companies") },
{ id: "go-settings", label: "Go to settings", hint: "Preferences and admin tools", action: () => onNavigate("/settings") },
{ id: "add-job", label: "Add new job", hint: "Open the add-job modal", action: onOpenAddJob },
{ id: "go-dashboard", label: t("goToDashboard"), hint: t("analyticsOverview"), action: () => onNavigate("/dashboard") },
{ id: "go-jobs", label: t("goToJobs"), hint: t("mainApplicationsTable"), action: () => onNavigate("/jobs") },
{ id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") },
{ id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") },
{ id: "go-settings", label: t("goToSettings"), hint: t("preferencesAndAdminTools"), action: () => onNavigate("/settings") },
{ id: "add-job", label: t("addNewJob"), hint: t("openAddJobModal"), action: onOpenAddJob },
];
const q = query.trim().toLowerCase();
if (!q) return base;
return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q));
}, [onNavigate, onOpenAddJob, query]);
}, [onNavigate, onOpenAddJob, query, t]);
const allItems: CommandItem[] = [
...commands,
...jobs.slice(0, 6).map((job) => ({
id: `job-${job.id}`,
label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`,
hint: "Open job list and search result",
label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`,
hint: t("openJobListAndSearchResult"),
action: () => onNavigate(`/jobs?open=${job.id}`),
})),
...companies.slice(0, 6).map((company) => ({
id: `company-${company.id}`,
label: company.name,
hint: "Open companies",
hint: t("openCompanies"),
action: () => onNavigate(`/companies?edit=${company.id}`),
})),
];
@@ -120,7 +122,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
autoFocus
variant="standard"
fullWidth
placeholder="Search jobs, companies, or actions"
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{ disableUnderline: true }}
@@ -133,7 +135,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
<List sx={{ mt: 1 }}>
{allItems.length === 0 ? (
<Box sx={{ px: 2, py: 3 }}>
<Typography sx={{ color: "text.secondary" }}>No matching commands or records.</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
</Box>
) : (
allItems.map((item) => (
+17 -14
View File
@@ -5,6 +5,7 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { JobApplication } from "../types";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import JobDetailsDialog from "./JobDetailsDialog";
@@ -28,6 +29,7 @@ function groupItems(items: JobApplication[]): ReminderGroups {
}
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
const { t } = useI18n();
if (items.length === 0) return null;
return (
@@ -40,17 +42,17 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}></span> {j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label="Follow up" /> : null}
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
<Chip size="small" label={j.status} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>Open</Button>
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>{t("remindersOpen")}</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>Clear</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
</Box>
</Paper>
))}
@@ -60,6 +62,7 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
export default function RemindersView() {
const { toast } = useToast();
const { t } = useI18n();
const [items, setItems] = useState<JobApplication[]>([]);
const [openJobId, setOpenJobId] = useState<number | null>(null);
@@ -78,32 +81,32 @@ export default function RemindersView() {
try {
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success");
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
await load();
} catch {
toast("Failed to set follow-up.", "error");
toast(t("remindersFollowUpFailed"), "error");
}
};
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Needs Follow-up</Typography>
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
Grouped by the most useful next action so you can fix gaps faster.
{t("remindersSubtitle")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title="Missing tailored CV" items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Missing interview prep" items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Follow-up due" items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Other reminders" items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>Nothing to follow up right now.</Typography> : null}
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Tip: focus on tailored CV and interview prep first for the highest-value roles.
{t("remindersTip")}
</Typography>
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
@@ -4,6 +4,7 @@ import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type RuleSettings = {
id: number;
@@ -17,6 +18,7 @@ type RuleSettings = {
export default function RulesSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const [s, setS] = useState<RuleSettings | null>(null);
const [saving, setSaving] = useState(false);
@@ -29,7 +31,7 @@ export default function RulesSettingsCard() {
setSaving(true);
try {
await api.put("/rules", s);
toast("Rules saved.", "success");
toast(t("rulesSave"), "success");
} catch {
toast("Failed to save rules.", "error");
} finally {
@@ -50,30 +52,29 @@ export default function RulesSettingsCard() {
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Follow-up + Ghosting Rules
{t("rulesTitle")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
Jobs get a Follow up flag based on these thresholds. Ghosting is automatic.
{t("rulesBody")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<TextField label="Applied: follow-up days" {...num("appliedFollowUpDays")} />
<TextField label="Applied: ghost days" {...num("appliedGhostDays")} />
<TextField label={t("rulesAppliedFollowUpDays")} {...num("appliedFollowUpDays")} />
<TextField label={t("rulesAppliedGhostDays")} {...num("appliedGhostDays")} />
<Box />
<TextField label="Offer: follow-up days" {...num("offerFollowUpDays")} />
<TextField label="Offer: ghost days" {...num("offerGhostDays")} />
<TextField label={t("rulesOfferFollowUpDays")} {...num("offerFollowUpDays")} />
<TextField label={t("rulesOfferGhostDays")} {...num("offerGhostDays")} />
<Box />
<TextField label="Feedback: follow-up days" {...num("feedbackFollowUpDays")} />
<TextField label="Feedback: ghost days" {...num("feedbackGhostDays")} />
<TextField label={t("rulesFeedbackFollowUpDays")} {...num("feedbackFollowUpDays")} />
<TextField label={t("rulesFeedbackGhostDays")} {...num("feedbackGhostDays")} />
<Box />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? "Saving..." : "Save Rules"}
{saving ? t("rulesSaving") : t("rulesSave")}
</Button>
</Box>
</Paper>
);
}
+62 -35
View File
@@ -12,7 +12,6 @@ import {
Select,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
@@ -22,8 +21,8 @@ import GoogleAuthCard from "./GoogleAuthCard";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import AuthStatusCard from "./AuthStatusCard";
export type ThemeModePref = "system" | "light" | "dark";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
interface Props {
pageSize: 15 | 20 | 25;
@@ -42,7 +41,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
return <Box sx={{ mt: 2 }}>{children}</Box>;
}
const ACCENTS = ["#7c4dff", "#4f8cff", "#16a34a", "#f59e0b", "#e11d48", "#06b6d4"]; // violet, blue, green, amber, rose, cyan
const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#0f766e", "#65a30d"];
export default function SettingsView({
pageSize,
@@ -56,56 +55,60 @@ export default function SettingsView({
onResetAccentColor,
}: Props) {
const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n();
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
Settings
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Preferences and admin tools.
{t("settingsSubtitle")}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 1 }}>
<Tab label="General" />
<Tab label="Follow-ups" />
<Tab label="Notifications" />
<Tab label="Account" />
<Tab label="Backup" />
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
<Tab label={t("settingsTabAccount")} />
<Tab label={t("settingsTabBackup")} />
</Tabs>
<TabPanel value={tab} index={0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>Appearance</Typography>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="theme-mode-label">Theme</InputLabel>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
value={themeMode}
label="Theme"
label={t("settingsTheme")}
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<TextField
label="Accent"
<input type="hidden" />
<FormControl sx={{ width: 160 }}>
<Typography variant="caption" sx={{ mb: 0.5 }}>{t("settingsAccent")}</Typography>
<input
aria-label={t("settingsAccent")}
type="color"
value={accentOk ? accentColor : "#7c4dff"}
value={accentOk ? accentColor : "#15803d"}
onChange={(e) => onAccentColorChange(e.target.value)}
sx={{ width: 160 }}
InputLabelProps={{ shrink: true }}
style={{ width: 160, height: 40, border: "none", background: "transparent", padding: 0 }}
/>
</FormControl>
<Button variant="outlined" onClick={onResetAccentColor}>
Reset
{t("settingsReset")}
</Button>
</Box>
@@ -129,24 +132,48 @@ export default function SettingsView({
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
Saved per user on this browser.
{t("settingsSavedPerUser")}
</Typography>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>Jobs</Typography>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsLanguageBody")}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
value={language}
label={t("settingsPreferredLanguage")}
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
>
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("settingsMorePagesSoon")}
</Typography>
</Paper>
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Pagination
{t("settingsPagination")}
</Typography>
<FormControl fullWidth>
<InputLabel id="page-size-label">Rows per page</InputLabel>
<InputLabel id="page-size-label">{t("settingsRowsPerPage")}</InputLabel>
<Select
labelId="page-size-label"
value={pageSize}
label="Rows per page"
label={t("settingsRowsPerPage")}
onChange={(e) => onPageSizeChange(e.target.value as 15 | 20 | 25)}
>
<MenuItem value={15}>15</MenuItem>
@@ -158,14 +185,14 @@ export default function SettingsView({
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Columns
{t("settingsColumns")}
</Typography>
{(
[
["status", "Status"],
["dateApplied", "Date applied"],
["daysSince", "Days"],
["jobUrl", "Job URL"],
["status", t("settingsColumnStatus")],
["dateApplied", t("settingsColumnDateApplied")],
["daysSince", t("settingsColumnDays")],
["jobUrl", t("settingsColumnJobUrl")],
] as const
).map(([key, label]) => (
<FormControlLabel
@@ -193,9 +220,9 @@ export default function SettingsView({
<TabPanel value={tab} index={2}>
<Paper sx={{ p: 2, mt: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Email notifications</Typography>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary" }}>
Notifications are sent via SMTP (Gmail works). Configure SMTP in the API (`Email:*` settings or env vars like `EMAIL_SMTP_HOST`).
{t("settingsNotificationsBody")}
</Typography>
</Paper>
</TabPanel>
+9 -2
View File
@@ -1,14 +1,21 @@
import React, { createContext, useContext, useState } from "react";
import { translations, TranslationKey, UiLanguage } from "./translations";
type TranslationParams = Record<string, string | number>;
type Ctx = {
language: UiLanguage;
setLanguage: (l: UiLanguage) => void;
t: (key: TranslationKey) => string;
t: (key: TranslationKey, params?: TranslationParams) => string;
};
const I18nContext = createContext<Ctx | null>(null);
function interpolate(template: string, params?: TranslationParams) {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<UiLanguage>(() => {
const raw = window.localStorage.getItem("uiLanguage");
@@ -20,7 +27,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
window.localStorage.setItem("uiLanguage", l);
};
const t = (key: TranslationKey) => translations[language][key] ?? translations.en[key];
const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params);
return <I18nContext.Provider value={{ language, setLanguage, t }}>{children}</I18nContext.Provider>;
}
+814 -3
View File
@@ -2,7 +2,8 @@ export type UiLanguage = "en" | "no";
export const translations = {
en: {
appTitle: "Job Tracker",
appTitle: "Jobbjakt",
appTagline: "Track your hunt",
dashboard: "Dashboard",
jobApplications: "Job Applications",
reminders: "Reminders",
@@ -14,9 +15,415 @@ export const translations = {
addJobApplication: "Add Job Application",
company: "Company",
location: "Location",
home: "Home",
analytics: "Analytics",
overview: "Overview",
account: "Account",
profile: "Profile",
admin: "Admin",
auditLog: "Audit log",
users: "Users",
system: "System",
systemStatus: "System status",
manage: "Manage",
notifications: "Notifications",
quickSearch: "Quick Search",
searchPlaceholder: "Search jobs, companies, or actions",
noMatchingCommands: "No matching commands or records.",
goToDashboard: "Go to dashboard",
analyticsOverview: "Analytics overview",
goToJobs: "Go to jobs",
mainApplicationsTable: "Main applications table",
goToReminders: "Go to reminders",
followUpQueue: "Follow-up queue",
goToCompanies: "Go to companies",
crmAndSourceTracking: "CRM and source tracking",
goToSettings: "Go to settings",
preferencesAndAdminTools: "Preferences and admin tools",
addNewJob: "Add new job",
openAddJobModal: "Open the add-job modal",
openCompanies: "Open companies",
openJobListAndSearchResult: "Open job list and search result",
profileMenu: "Profile",
settingsMenu: "Settings",
signOut: "Sign out",
user: "User",
superAdmin: "Super Admin",
close: "Close",
cancel: "Cancel",
save: "Save",
create: "Create",
createJob: "Create job",
createAndAddAnother: "Create & add another",
loading: "Loading...",
notFoundTitle: "Page not found",
notFoundBody: "The page you were looking for does not exist or may have moved.",
appErrorTitle: "Something went wrong",
appErrorBody: "An unexpected error occurred while loading this page.",
goHome: "Go home",
goBack: "Go back",
addJobModalCompanySection: "Company",
addJobModalCreateCompany: "Create \"{name}\"",
addJobModalCompanyLocation: "Company location",
addJobModalCompanySource: "Company source",
addJobModalPossibleDuplicates: "Possible duplicates found",
addJobModalJobApplicationSection: "Job application",
addJobModalJobUrl: "Job URL",
addJobModalImportFromUrl: "Import from URL",
addJobModalImporting: "Importing...",
addJobModalDateApplied: "Date applied",
addJobModalStatus: "Status",
addJobModalJobTitle: "Job title",
addJobModalSalary: "Salary",
addJobModalDeadline: "Deadline",
addJobModalDescriptionOriginal: "Description (original)",
addJobModalTranslatedDescription: "Translated description ({language})",
addJobModalDescriptionLanguage: "Description language",
addJobModalTranslatedShown: "Shown because the source language differs from your preferred language ({language}).",
addJobModalTranslatedHidden: "Translated text is only shown when the source language differs from your preferred language.",
addJobModalNotes: "Notes",
addJobModalDocuments: "Documents",
addJobModalResume: "CV / resume",
addJobModalResumeHelp: "Prefer PDF, DOC, or DOCX. Text extraction can happen later where supported.",
addJobModalCoverLetter: "Cover letter",
addJobModalCoverLetterHelp: "Upload one or more versions instead of pasting long text into the form.",
addJobModalPortfolio: "Portfolio",
addJobModalPortfolioHelp: "Use PDF, DOC, DOCX, TXT, MD, or image files for scans/screenshots.",
addJobModalOtherFiles: "Other files",
addJobModalOtherFilesHelp: "Certificates, references, role briefs, or any supporting documents.",
addJobModalChooseFiles: "Choose files",
addJobModalNoFilesSelected: "No files selected",
addJobModalFilesSelected: "{count} files selected",
addJobModalFileReady: "{count} file ready",
addJobModalFilesReady: "{count} files ready",
addJobModalPreferredFiles: "Preferred: PDF, DOC, DOCX",
addJobModalTextImageAllowed: "Text and image files also allowed",
addJobModalPasteUrlFirst: "Paste a job URL first.",
addJobModalImported: "Imported.",
addJobModalImportFailed: "Import failed.",
addJobModalFailedCreateCompany: "Failed to create company.",
addJobModalSelectCompany: "Select or create a company.",
addJobModalJobAdded: "Job added.",
addJobModalJobAndFilesAdded: "Job and files added.",
addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.",
addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.",
addJobModalFailedAddJob: "Failed to add job.",
statusApplied: "Applied",
statusWaiting: "Waiting",
statusInterview: "Interview",
statusOffer: "Offer",
statusRejected: "Rejected",
statusGhosted: "Ghosted",
settingsTitle: "Settings",
settingsSubtitle: "Preferences and admin tools.",
settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications",
settingsTabAccount: "Account",
settingsTabBackup: "Backup",
settingsAppearance: "Appearance",
settingsTheme: "Theme",
settingsThemeSystem: "System",
settingsThemeDark: "Dark",
settingsThemeLight: "Light",
settingsAccent: "Accent",
settingsReset: "Reset",
settingsSavedPerUser: "Saved per user on this browser.",
settingsLanguageTitle: "Language and localization",
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
settingsPreferredLanguage: "Preferred language",
settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål",
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
settingsJobs: "Jobs",
settingsPagination: "Pagination",
settingsRowsPerPage: "Rows per page",
settingsColumns: "Columns",
settingsColumnStatus: "Status",
settingsColumnDateApplied: "Date applied",
settingsColumnDays: "Days",
settingsColumnJobUrl: "Job URL",
settingsNotificationsTitle: "Email notifications",
settingsNotificationsBody: "Notifications are sent via SMTP (Gmail works). Configure SMTP in the API (`Email:*` settings or env vars like `EMAIL_SMTP_HOST`).",
profileTitle: "Profile",
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
profileLocalAccount: "Local account",
profileGoogleSession: "Google session",
profileExternalSession: "External session",
profileGoogleLinked: "Google linked",
profileGoogleLinkedWithEmail: "Google linked: {email}",
profileGoogleNotLinked: "Google not linked",
profileCvReady: "CV ready · {count} words",
profileCvMissing: "CV missing",
profileChangeImage: "Change image",
profileRemoveImage: "Remove",
profileImageUpdated: "Profile image updated.",
profileImageRemoved: "Profile image removed.",
profileImageUploadFailed: "Failed to upload profile image.",
profileImageRemoveFailed: "Failed to remove profile image.",
profileAccountSection: "Account",
profileReadOnlyInfo: "This session is not using a local app token, so profile edits are read-only right now.",
profileDisplayName: "Display name",
profileUsername: "Username",
profileFirstName: "First name",
profileLastName: "Last name",
profileEmail: "Email",
profileHeadline: "Profile headline",
profileHeadlineHelp: "Stored only in this browser to personalize your workspace.",
profileMasterCv: "Master CV",
profileMasterCvBody: "Upload a PDF, Word document, plain text file, markdown file, or image scan. Where supported, the app can extract text automatically and populate your master CV text for tailoring and outreach.",
profileUploadCv: "Upload CV",
profileUploading: "Uploading...",
profileCopyCvText: "Copy CV text",
profileCvUploaded: "CV uploaded and processed.",
profileCvUploadFailed: "Failed to upload CV.",
profileCvTextLabel: "Profile CV / master resume text",
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
profileCvPreferredUploads: "Preferred uploads: PDF, DOC, DOCX. Text and image files are also accepted.",
profileSaveChanges: "Save changes",
profileUpdated: "Profile updated.",
profileUpdateFailed: "Failed to update profile.",
profileChangePassword: "Change password",
profilePasswordLocalOnly: "Password changes are only available for local accounts.",
profileCurrentPassword: "Current password",
profileNewPassword: "New password",
profileUpdatePassword: "Update password",
profilePasswordUpdated: "Password updated.",
profilePasswordUpdateFailed: "Failed to change password.",
cropDialogTitle: "Crop profile image",
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
cropDialogZoom: "Zoom",
cropDialogSave: "Save image",
dashboardOverviewTitle: "Dashboard overview",
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",
dashboardSummaryCards: "Summary cards",
dashboardActivityChart: "Activity chart",
dashboardConversionFunnel: "Conversion funnel",
dashboardTopCompanies: "Top companies",
dashboardSkillsInsights: "Skills insights",
dashboardActiveApplications: "Active applications",
dashboardCurrentlyInProgress: "Currently in progress",
dashboardApplied30Days: "Applied (30 days)",
dashboardNewApplications: "New applications",
dashboardMedianFirstResponse: "Median first response",
dashboardDaysUntilFirstReply: "Days until first reply",
dashboardResponsesLogged: "Responses logged",
dashboardAcrossActiveJobs: "Across active jobs",
dashboardLowReadiness: "Low readiness",
dashboardMissingTailoredCv: "Reminder jobs missing tailored CV",
dashboardApplicationActivity: "Application activity",
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
dashboardConversionFunnelTitle: "Conversion funnel",
dashboardResponseSources: "Response sources",
dashboardTopCompaniesByActivity: "Top companies by activity",
dashboardTopSkills: "Top skills",
dashboardNoTagsYet: "No tags yet.",
dashboardSkillTags: "skill tags",
dashboardSkillTrends: "Skill trends",
dashboardNoTagTrendData: "No tag trend data yet.",
remindersTitle: "Needs Follow-up",
remindersSubtitle: "Grouped by the most useful next action so you can fix gaps faster.",
remindersMissingTailoredCv: "Missing tailored CV",
remindersMissingInterviewPrep: "Missing interview prep",
remindersFollowUpDue: "Follow-up due",
remindersOther: "Other reminders",
remindersNothing: "Nothing to follow up right now.",
remindersTip: "Tip: focus on tailored CV and interview prep first for the highest-value roles.",
remindersOpen: "Open",
remindersClear: "Clear",
remindersFollowUpLabel: "Follow up",
remindersFollowUpDate: "Follow-up: {date}",
remindersFollowUpCleared: "Follow-up cleared.",
remindersFollowUpSet: "Follow-up set.",
remindersFollowUpFailed: "Failed to set follow-up.",
companiesEmpty: "No companies yet.",
companiesName: "Name",
companiesLocation: "Location",
companiesSource: "Source",
companiesPipeline: "Pipeline",
companiesRecruiter: "Recruiter",
companiesNextContact: "Next Contact",
companiesEdit: "Edit Company",
companiesPipelineStage: "Pipeline stage",
companiesRecruiterName: "Recruiter name",
companiesRecruiterEmail: "Recruiter email",
companiesRecruiterLinkedIn: "Recruiter LinkedIn",
companiesLastContacted: "Last contacted",
companiesNextContactField: "Next contact",
companiesUpdated: "Company updated.",
companiesUpdateFailed: "Failed to update company.",
adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user",
adminUsersAdmin: "Admin",
adminUsersSendReset: "Send reset",
adminUsersDelete: "Delete",
adminUsersConfirmed: "Confirmed",
adminUsersActions: "Actions",
adminUsersNoUsers: "No users.",
adminUsersRolesUpdated: "Roles updated.",
adminUsersRolesUpdateFailed: "Failed to update roles.",
adminUsersResetSent: "Password reset email sent.",
adminUsersResetFailed: "Failed to send reset.",
adminUsersDeleteConfirmTitle: "Delete user",
adminUsersDeleted: "User deleted.",
adminUsersDeleteFailed: "Failed to delete user.",
adminUsersCreated: "User created.",
adminUsersCreateFailed: "Failed to create user.",
adminSystemTitle: "System status",
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, and summarizer health.",
adminSystemRunProbe: "Run probe now",
adminSystemRunningProbe: "Running probe...",
adminSystemRefresh: "Refresh",
adminSystemRefreshing: "Refreshing...",
adminSystemEnvironment: "Environment",
adminSystemDatabase: "Database",
adminSystemConnected: "Connected",
adminSystemOffline: "Offline",
adminSystemSmtp: "SMTP",
adminSystemEnabled: "Enabled",
adminSystemDisabled: "Disabled",
adminSystemSummarizer: "Summarizer",
adminSystemHealthy: "Healthy",
adminSystemNoLatencyData: "No latency data",
adminSystemDatabaseStorage: "Database and storage",
adminSystemRuntimeAuth: "Runtime and auth",
adminSystemEmailConfig: "Email configuration",
adminSystemSummarizerRuntime: "Summarizer runtime",
adminSystemSmtpTest: "SMTP test email",
adminSystemSmtpTestBody: "Send a quick delivery check using the configured SMTP settings. Leave the recipient blank to use your admin email.",
adminSystemRecipientEmail: "Recipient email",
adminSystemRecipientPlaceholder: "Uses your admin email if left blank",
adminSystemSubject: "Subject",
adminSystemMessage: "Message",
adminSystemSendTestEmail: "Send test email",
adminSystemSending: "Sending...",
adminSystemSummarizerTelemetry: "Summarizer telemetry",
adminSystemDatabaseConnected: "Database connected",
adminSystemDatabaseIssue: "Database issue",
adminSystemAuthEnforced: "Auth enforced",
adminSystemAuthOptional: "Auth optional",
adminSystemGoogleReady: "Google sign-in ready",
adminSystemGoogleOff: "Google sign-in off",
adminSystemGmailReady: "Gmail ready",
adminSystemGmailIncomplete: "Gmail incomplete",
adminSystemGpuVisible: "GPU visible",
adminSystemCpuMode: "CPU mode",
adminSystemNoSmtpHost: "No SMTP host configured",
googleAccountTitle: "Google account",
googleSetupHint: "Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleLinked: "Linked",
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
googleLinkedTo: "Linked to {email}.",
googleLinkedToYourAccount: "Linked to your Google account.",
googleBindHint: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data.",
googleExchangeHint: "Exchange your Google sign-in for a normal Jobbjakt session.",
googleSignedIn: "Signed in with Google.",
googleNotLinkedYet: "This Google account is not linked yet. Sign in locally first to bind it.",
googleLinkedSuccess: "Google account linked.",
googleLinkedSuccessWithEmail: "Linked Google account {email}.",
googleAuthFailed: "Google authentication failed.",
googleScriptLoadFailed: "Google auth script failed to load.",
googleUnlinked: "Google account unlinked.",
googleUnlinkFailed: "Failed to unlink Google account.",
signedOut: "Signed out.",
signedInAs: "Signed in as {name}.",
unlinkGoogle: "Unlink Google",
importExportTitle: "Import / Export",
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
exportJson: "Export JSON",
exportCsv: "Export CSV",
importJson: "Import JSON",
convertCsvToImportJson: "Convert CSV to Import JSON",
exportedJobs: "Exported jobs ({format}).",
exportFailed: "Export failed.",
importedJobs: "Imported {count} jobs.",
importFailedJson: "Import failed (expecting exported JSON array).",
convertedRows: "Converted {count} rows to import JSON.",
csvConversionFailed: "CSV conversion failed.",
lastImport: "Last import: {count}",
signInTitle: "Sign in",
authRequired: "Authentication is required to use this app.",
authOptional: "Authentication is optional in this environment.",
emailAndPassword: "Email & password",
google: "Google",
createAccount: "Create account",
signedIn: "Signed in.",
loginFailed: "Login failed.",
resetPasswordTitle: "Reset password",
resetPasswordBody: "Set a new password for your account.",
missingResetLinkInfo: "Missing email/token in link.",
passwordResetSuccess: "Password reset. Please sign in.",
resetFailed: "Reset failed.",
backToLogin: "Back to login",
updatePassword: "Update password",
jobTableSearch: "Search",
jobTableSearchPlaceholder: "Title, company, notes, messages",
jobTableStatus: "Status",
jobTableAll: "All",
jobTableCompany: "Company",
jobTableLocation: "Location",
jobTableNeedsFollowUp: "Needs follow-up",
jobTableReadiness: "Readiness",
jobTableAllReadiness: "All readiness",
jobTableNeedsWork: "Needs work",
jobTableInterviewStage: "Interview stage",
jobTableShowDeleted: "Show deleted",
jobTableColumns: "Columns",
jobTableSelected: "{count} selected",
jobTableRestoreSelected: "Restore selected",
jobTableDeleteSelected: "Delete selected",
jobTableUpdatedJobs: "Updated {count} jobs.",
jobTableBulkActionFailed: "Bulk action failed.",
jobTableMoveToTrashTitle: "Move job to trash",
jobTableMoveJobsToTrashTitle: "Move jobs to trash",
jobTableMove: "Move",
jobTableMoveOneConfirm: "Move \"{title}\" at {company} to trash?",
jobTableMoveManyConfirm: "Move {count} selected jobs to trash?",
jobTableMovedToTrash: "Job moved to trash.",
jobTableDeleteFailed: "Failed to delete job.",
jobTableRestored: "Job restored.",
jobTableRestoreFailed: "Failed to restore job.",
jobTableStatusSet: "Status set to {status}.",
jobTableStatusUpdateFailed: "Failed to update status.",
jobTableDateApplied: "Date Applied",
jobTableDays: "Days",
jobTableRole: "Role",
jobTableActions: "Actions",
jobTableLink: "Link",
jobTableEdit: "Edit",
jobTableQuickStatus: "Quick status",
jobTableOpen: "Open",
jobTableSoftDelete: "Soft delete",
jobTableRestore: "Restore",
jobTableFollowUp: "Follow up",
jobTableCvMissing: "CV missing",
jobTableCvReady: "CV ready",
jobTableOpenListing: "Open listing",
jobTableSkills: "Skills",
jobTableNoTags: "No tags",
jobTableOverview: "Overview",
jobTableNoSummaryYet: "No summary yet.",
jobTableNoJobsFound: "No jobs found.",
jobTableSetStatus: "Set {status}",
editJobTitle: "Edit job",
rulesTitle: "Follow-up + Ghosting Rules",
rulesBody: "Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic.",
rulesAppliedFollowUpDays: "Applied: follow-up days",
rulesAppliedGhostDays: "Applied: ghost days",
rulesOfferFollowUpDays: "Offer: follow-up days",
rulesOfferGhostDays: "Offer: ghost days",
rulesFeedbackFollowUpDays: "Feedback: follow-up days",
rulesFeedbackGhostDays: "Feedback: ghost days",
rulesSaving: "Saving...",
rulesSave: "Save Rules",
},
no: {
appTitle: "Jobbsporing",
appTitle: "Jobbjakt",
appTagline: "Hold oversikt over jobbsøkingen",
dashboard: "Dashboard",
jobApplications: "Jobbsøknader",
reminders: "Påminnelser",
@@ -28,8 +435,412 @@ export const translations = {
addJobApplication: "Legg til jobbsøknad",
company: "Selskap",
location: "Sted",
home: "Hjem",
analytics: "Analyse",
overview: "Oversikt",
account: "Konto",
profile: "Profil",
admin: "Admin",
auditLog: "Revisjonslogg",
users: "Brukere",
system: "System",
systemStatus: "Systemstatus",
manage: "Administrer",
notifications: "Varsler",
quickSearch: "Hurtigsøk",
searchPlaceholder: "Søk etter jobber, selskaper eller handlinger",
noMatchingCommands: "Ingen treff på kommandoer eller poster.",
goToDashboard: "Gå til dashboard",
analyticsOverview: "Analyseoversikt",
goToJobs: "Gå til jobber",
mainApplicationsTable: "Hovedtabell for søknader",
goToReminders: "Gå til påminnelser",
followUpQueue: "Oppfølgingskø",
goToCompanies: "Gå til selskaper",
crmAndSourceTracking: "CRM og kildesporing",
goToSettings: "Gå til innstillinger",
preferencesAndAdminTools: "Preferanser og adminverktøy",
addNewJob: "Legg til ny jobb",
openAddJobModal: "Åpne dialogen for ny jobb",
openCompanies: "Åpne selskaper",
openJobListAndSearchResult: "Åpne jobbliste og søkeresultat",
profileMenu: "Profil",
settingsMenu: "Innstillinger",
signOut: "Logg ut",
user: "Bruker",
superAdmin: "Superadmin",
close: "Lukk",
cancel: "Avbryt",
save: "Lagre",
create: "Opprett",
createJob: "Opprett jobb",
createAndAddAnother: "Opprett og legg til en til",
loading: "Laster...",
notFoundTitle: "Siden ble ikke funnet",
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
appErrorTitle: "Noe gikk galt",
appErrorBody: "Det oppstod en uventet feil under lasting av siden.",
goHome: "Gå hjem",
goBack: "Gå tilbake",
addJobModalCompanySection: "Selskap",
addJobModalCreateCompany: "Opprett \"{name}\"",
addJobModalCompanyLocation: "Selskapssted",
addJobModalCompanySource: "Selskapskilde",
addJobModalPossibleDuplicates: "Mulige duplikater funnet",
addJobModalJobApplicationSection: "Jobbsøknad",
addJobModalJobUrl: "Jobb-URL",
addJobModalImportFromUrl: "Importer fra URL",
addJobModalImporting: "Importerer...",
addJobModalDateApplied: "Søkt dato",
addJobModalStatus: "Status",
addJobModalJobTitle: "Stillingstittel",
addJobModalSalary: "Lønn",
addJobModalDeadline: "Frist",
addJobModalDescriptionOriginal: "Beskrivelse (original)",
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
addJobModalDescriptionLanguage: "Språk i beskrivelse",
addJobModalTranslatedShown: "Vises fordi kildespråket er forskjellig fra ditt foretrukne språk ({language}).",
addJobModalTranslatedHidden: "Oversatt tekst vises bare når kildespråket er forskjellig fra ditt foretrukne språk.",
addJobModalNotes: "Notater",
addJobModalDocuments: "Dokumenter",
addJobModalResume: "CV / resymé",
addJobModalResumeHelp: "Foretrekk PDF, DOC eller DOCX. Tekstuttrekk kan skje senere der det støttes.",
addJobModalCoverLetter: "Søknadsbrev",
addJobModalCoverLetterHelp: "Last opp én eller flere versjoner i stedet for å lime inn lang tekst i skjemaet.",
addJobModalPortfolio: "Portefølje",
addJobModalPortfolioHelp: "Bruk PDF, DOC, DOCX, TXT, MD eller bildefiler for skannede dokumenter/skjermbilder.",
addJobModalOtherFiles: "Andre filer",
addJobModalOtherFilesHelp: "Sertifikater, referanser, rollebeskrivelser eller andre vedlegg.",
addJobModalChooseFiles: "Velg filer",
addJobModalNoFilesSelected: "Ingen filer valgt",
addJobModalFilesSelected: "{count} filer valgt",
addJobModalFileReady: "{count} fil klar",
addJobModalFilesReady: "{count} filer klare",
addJobModalPreferredFiles: "Foretrukket: PDF, DOC, DOCX",
addJobModalTextImageAllowed: "Tekst- og bildefiler er også tillatt",
addJobModalPasteUrlFirst: "Lim inn en jobb-URL først.",
addJobModalImported: "Importert.",
addJobModalImportFailed: "Import mislyktes.",
addJobModalFailedCreateCompany: "Kunne ikke opprette selskap.",
addJobModalSelectCompany: "Velg eller opprett et selskap.",
addJobModalJobAdded: "Jobb lagt til.",
addJobModalJobAndFilesAdded: "Jobb og filer lagt til.",
addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.",
addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.",
addJobModalFailedAddJob: "Kunne ikke legge til jobb.",
statusApplied: "Søkt",
statusWaiting: "Venter",
statusInterview: "Intervju",
statusOffer: "Tilbud",
statusRejected: "Avslått",
statusGhosted: "Ghostet",
settingsTitle: "Innstillinger",
settingsSubtitle: "Preferanser og adminverktøy.",
settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler",
settingsTabAccount: "Konto",
settingsTabBackup: "Sikkerhetskopi",
settingsAppearance: "Utseende",
settingsTheme: "Tema",
settingsThemeSystem: "System",
settingsThemeDark: "Mørkt",
settingsThemeLight: "Lyst",
settingsAccent: "Aksent",
settingsReset: "Tilbakestill",
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
settingsLanguageTitle: "Språk og lokalisering",
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
settingsPreferredLanguage: "Foretrukket språk",
settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål",
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
settingsJobs: "Jobber",
settingsPagination: "Paginering",
settingsRowsPerPage: "Rader per side",
settingsColumns: "Kolonner",
settingsColumnStatus: "Status",
settingsColumnDateApplied: "Søkt dato",
settingsColumnDays: "Dager",
settingsColumnJobUrl: "Jobb-URL",
settingsNotificationsTitle: "E-postvarsler",
settingsNotificationsBody: "Varsler sendes via SMTP (Gmail fungerer). Konfigurer SMTP i API-et (`Email:*`-innstillinger eller miljøvariabler som `EMAIL_SMTP_HOST`).",
profileTitle: "Profil",
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
profileLocalAccount: "Lokal konto",
profileGoogleSession: "Google-økt",
profileExternalSession: "Ekstern økt",
profileGoogleLinked: "Google koblet",
profileGoogleLinkedWithEmail: "Google koblet: {email}",
profileGoogleNotLinked: "Google ikke koblet",
profileCvReady: "CV klart · {count} ord",
profileCvMissing: "CV mangler",
profileChangeImage: "Bytt bilde",
profileRemoveImage: "Fjern",
profileImageUpdated: "Profilbildet ble oppdatert.",
profileImageRemoved: "Profilbildet ble fjernet.",
profileImageUploadFailed: "Kunne ikke laste opp profilbildet.",
profileImageRemoveFailed: "Kunne ikke fjerne profilbildet.",
profileAccountSection: "Konto",
profileReadOnlyInfo: "Denne økten bruker ikke et lokalt apptoken, så profilendringer er skrivebeskyttet akkurat nå.",
profileDisplayName: "Visningsnavn",
profileUsername: "Brukernavn",
profileFirstName: "Fornavn",
profileLastName: "Etternavn",
profileEmail: "E-post",
profileHeadline: "Profiloverskrift",
profileHeadlineHelp: "Lagres bare i denne nettleseren for å gjøre arbeidsområdet mer personlig.",
profileMasterCv: "Hoved-CV",
profileMasterCvBody: "Last opp en PDF, et Word-dokument, en ren tekstfil, en markdown-fil eller et bildeskann. Der det støttes kan appen automatisk hente ut tekst og fylle inn hoved-CV-en din for tilpasning og kontakt.",
profileUploadCv: "Last opp CV",
profileUploading: "Laster opp...",
profileCopyCvText: "Kopier CV-tekst",
profileCvUploaded: "CV lastet opp og behandlet.",
profileCvUploadFailed: "Kunne ikke laste opp CV.",
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
profileCvPreferredUploads: "Foretrukne opplastinger: PDF, DOC, DOCX. Tekst- og bildefiler aksepteres også.",
profileSaveChanges: "Lagre endringer",
profileUpdated: "Profil oppdatert.",
profileUpdateFailed: "Kunne ikke oppdatere profil.",
profileChangePassword: "Bytt passord",
profilePasswordLocalOnly: "Passordendringer er bare tilgjengelige for lokale kontoer.",
profileCurrentPassword: "Nåværende passord",
profileNewPassword: "Nytt passord",
profileUpdatePassword: "Oppdater passord",
profilePasswordUpdated: "Passord oppdatert.",
profilePasswordUpdateFailed: "Kunne ikke endre passord.",
cropDialogTitle: "Beskjær profilbilde",
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
cropDialogZoom: "Zoom",
cropDialogSave: "Lagre bilde",
dashboardOverviewTitle: "Dashboard-oversikt",
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",
dashboardSummaryCards: "Oppsummeringskort",
dashboardActivityChart: "Aktivitetsgraf",
dashboardConversionFunnel: "Konverteringstrakt",
dashboardTopCompanies: "Topp selskaper",
dashboardSkillsInsights: "Kompetanseinnsikt",
dashboardActiveApplications: "Aktive søknader",
dashboardCurrentlyInProgress: "Pågår nå",
dashboardApplied30Days: "Søkt (30 dager)",
dashboardNewApplications: "Nye søknader",
dashboardMedianFirstResponse: "Median første svar",
dashboardDaysUntilFirstReply: "Dager til første svar",
dashboardResponsesLogged: "Registrerte svar",
dashboardAcrossActiveJobs: "På tvers av aktive jobber",
dashboardLowReadiness: "Lav beredskap",
dashboardMissingTailoredCv: "Påminnelsesjobber uten skreddersydd CV",
dashboardApplicationActivity: "Søknadsaktivitet",
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
dashboardConversionFunnelTitle: "Konverteringstrakt",
dashboardResponseSources: "Svar etter kilde",
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
dashboardTopSkills: "Topp ferdigheter",
dashboardNoTagsYet: "Ingen tagger ennå.",
dashboardSkillTags: "ferdighetstagger",
dashboardSkillTrends: "Ferdighetstrender",
dashboardNoTagTrendData: "Ingen trenddata for tagger ennå.",
remindersTitle: "Trenger oppfølging",
remindersSubtitle: "Gruppert etter den mest nyttige neste handlingen slik at du kan lukke hull raskere.",
remindersMissingTailoredCv: "Mangler skreddersydd CV",
remindersMissingInterviewPrep: "Mangler intervjuforberedelse",
remindersFollowUpDue: "Oppfølging forfaller",
remindersOther: "Andre påminnelser",
remindersNothing: "Ingenting å følge opp akkurat nå.",
remindersTip: "Tips: fokuser på skreddersydd CV og intervjuforberedelse først for de mest verdifulle rollene.",
remindersOpen: "Åpne",
remindersClear: "Fjern",
remindersFollowUpLabel: "Følg opp",
remindersFollowUpDate: "Oppfølging: {date}",
remindersFollowUpCleared: "Oppfølging fjernet.",
remindersFollowUpSet: "Oppfølging satt.",
remindersFollowUpFailed: "Kunne ikke sette oppfølging.",
companiesEmpty: "Ingen selskaper ennå.",
companiesName: "Navn",
companiesLocation: "Sted",
companiesSource: "Kilde",
companiesPipeline: "Pipeline",
companiesRecruiter: "Rekrutterer",
companiesNextContact: "Neste kontakt",
companiesEdit: "Rediger selskap",
companiesPipelineStage: "Pipelinetrinn",
companiesRecruiterName: "Navn på rekrutterer",
companiesRecruiterEmail: "E-post til rekrutterer",
companiesRecruiterLinkedIn: "Rekrutterer på LinkedIn",
companiesLastContacted: "Sist kontaktet",
companiesNextContactField: "Neste kontakt",
companiesUpdated: "Selskap oppdatert.",
companiesUpdateFailed: "Kunne ikke oppdatere selskap.",
adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker",
adminUsersAdmin: "Admin",
adminUsersSendReset: "Send tilbakestilling",
adminUsersDelete: "Slett",
adminUsersConfirmed: "Bekreftet",
adminUsersActions: "Handlinger",
adminUsersNoUsers: "Ingen brukere.",
adminUsersRolesUpdated: "Roller oppdatert.",
adminUsersRolesUpdateFailed: "Kunne ikke oppdatere roller.",
adminUsersResetSent: "E-post for passordtilbakestilling sendt.",
adminUsersResetFailed: "Kunne ikke sende tilbakestilling.",
adminUsersDeleteConfirmTitle: "Slett bruker",
adminUsersDeleted: "Bruker slettet.",
adminUsersDeleteFailed: "Kunne ikke slette bruker.",
adminUsersCreated: "Bruker opprettet.",
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
adminSystemTitle: "Systemstatus",
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post og oppsummeringshelse.",
adminSystemRunProbe: "Kjør probe nå",
adminSystemRunningProbe: "Kjører probe...",
adminSystemRefresh: "Oppdater",
adminSystemRefreshing: "Oppdaterer...",
adminSystemEnvironment: "Miljø",
adminSystemDatabase: "Database",
adminSystemConnected: "Tilkoblet",
adminSystemOffline: "Frakoblet",
adminSystemSmtp: "SMTP",
adminSystemEnabled: "Aktivert",
adminSystemDisabled: "Deaktivert",
adminSystemSummarizer: "Oppsummerer",
adminSystemHealthy: "Frisk",
adminSystemNoLatencyData: "Ingen latensdata",
adminSystemDatabaseStorage: "Database og lagring",
adminSystemRuntimeAuth: "Kjøretid og autentisering",
adminSystemEmailConfig: "E-postkonfigurasjon",
adminSystemSummarizerRuntime: "Oppsummeringskjøretid",
adminSystemSmtpTest: "SMTP-test e-post",
adminSystemSmtpTestBody: "Send en rask leveringssjekk med de konfigurerte SMTP-innstillingene. La mottakeren stå tom for å bruke admin-eposten din.",
adminSystemRecipientEmail: "Mottaker e-post",
adminSystemRecipientPlaceholder: "Bruker admin-eposten din hvis feltet står tomt",
adminSystemSubject: "Emne",
adminSystemMessage: "Melding",
adminSystemSendTestEmail: "Send test-e-post",
adminSystemSending: "Sender...",
adminSystemSummarizerTelemetry: "Oppsummeringstelemetri",
adminSystemDatabaseConnected: "Database tilkoblet",
adminSystemDatabaseIssue: "Databaseproblem",
adminSystemAuthEnforced: "Autentisering påkrevd",
adminSystemAuthOptional: "Autentisering valgfri",
adminSystemGoogleReady: "Google-innlogging klar",
adminSystemGoogleOff: "Google-innlogging av",
adminSystemGmailReady: "Gmail klar",
adminSystemGmailIncomplete: "Gmail ufullstendig",
adminSystemGpuVisible: "GPU synlig",
adminSystemCpuMode: "CPU-modus",
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
googleAccountTitle: "Google-konto",
googleSetupHint: "Sett `REACT_APP_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleLinked: "Koblet",
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
googleLinkedTo: "Koblet til {email}.",
googleLinkedToYourAccount: "Koblet til Google-kontoen din.",
googleBindHint: "Koble en Google-konto til denne brukeren slik at du kan logge inn med Google og fortsatt beholde vanlige approller og data.",
googleExchangeHint: "Bytt Google-innloggingen din mot en vanlig Jobbjakt-økt.",
googleSignedIn: "Logget inn med Google.",
googleNotLinkedYet: "Denne Google-kontoen er ikke koblet ennå. Logg inn lokalt først for å koble den.",
googleLinkedSuccess: "Google-konto koblet.",
googleLinkedSuccessWithEmail: "Koblet Google-konto {email}.",
googleAuthFailed: "Google-autentisering mislyktes.",
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
googleUnlinked: "Google-konto koblet fra.",
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
signedOut: "Logget ut.",
signedInAs: "Logget inn som {name}.",
unlinkGoogle: "Koble fra Google",
importExportTitle: "Import / eksport",
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
exportJson: "Eksporter JSON",
exportCsv: "Eksporter CSV",
importJson: "Importer JSON",
convertCsvToImportJson: "Konverter CSV til import-JSON",
exportedJobs: "Eksporterte jobber ({format}).",
exportFailed: "Eksport mislyktes.",
importedJobs: "Importerte {count} jobber.",
importFailedJson: "Import mislyktes (forventer eksportert JSON-array).",
convertedRows: "Konverterte {count} rader til import-JSON.",
csvConversionFailed: "CSV-konvertering mislyktes.",
lastImport: "Siste import: {count}",
signInTitle: "Logg inn",
authRequired: "Autentisering er påkrevd for å bruke denne appen.",
authOptional: "Autentisering er valgfri i dette miljøet.",
emailAndPassword: "E-post og passord",
google: "Google",
createAccount: "Opprett konto",
signedIn: "Logget inn.",
loginFailed: "Innlogging mislyktes.",
resetPasswordTitle: "Tilbakestill passord",
resetPasswordBody: "Sett et nytt passord for kontoen din.",
missingResetLinkInfo: "Mangler e-post/token i lenken.",
passwordResetSuccess: "Passord tilbakestilt. Vennligst logg inn.",
resetFailed: "Tilbakestilling mislyktes.",
backToLogin: "Tilbake til innlogging",
updatePassword: "Oppdater passord",
jobTableSearch: "Søk",
jobTableSearchPlaceholder: "Tittel, selskap, notater, meldinger",
jobTableStatus: "Status",
jobTableAll: "Alle",
jobTableCompany: "Selskap",
jobTableLocation: "Sted",
jobTableNeedsFollowUp: "Trenger oppfølging",
jobTableReadiness: "Beredskap",
jobTableAllReadiness: "All beredskap",
jobTableNeedsWork: "Trenger arbeid",
jobTableInterviewStage: "Intervjustadium",
jobTableShowDeleted: "Vis slettede",
jobTableColumns: "Kolonner",
jobTableSelected: "{count} valgt",
jobTableRestoreSelected: "Gjenopprett valgte",
jobTableDeleteSelected: "Slett valgte",
jobTableUpdatedJobs: "Oppdaterte {count} jobber.",
jobTableBulkActionFailed: "Massehandling mislyktes.",
jobTableMoveToTrashTitle: "Flytt jobb til papirkurv",
jobTableMoveJobsToTrashTitle: "Flytt jobber til papirkurv",
jobTableMove: "Flytt",
jobTableMoveOneConfirm: "Flytt \"{title}\" hos {company} til papirkurven?",
jobTableMoveManyConfirm: "Flytt {count} valgte jobber til papirkurven?",
jobTableMovedToTrash: "Jobb flyttet til papirkurven.",
jobTableDeleteFailed: "Kunne ikke slette jobb.",
jobTableRestored: "Jobb gjenopprettet.",
jobTableRestoreFailed: "Kunne ikke gjenopprette jobb.",
jobTableStatusSet: "Status satt til {status}.",
jobTableStatusUpdateFailed: "Kunne ikke oppdatere status.",
jobTableDateApplied: "Søkt dato",
jobTableDays: "Dager",
jobTableRole: "Rolle",
jobTableActions: "Handlinger",
jobTableLink: "Lenke",
jobTableEdit: "Rediger",
jobTableQuickStatus: "Hurtigstatus",
jobTableOpen: "Åpne",
jobTableSoftDelete: "Myk sletting",
jobTableRestore: "Gjenopprett",
jobTableFollowUp: "Følg opp",
jobTableCvMissing: "CV mangler",
jobTableCvReady: "CV klar",
jobTableOpenListing: "Åpne stilling",
jobTableSkills: "Ferdigheter",
jobTableNoTags: "Ingen tagger",
jobTableOverview: "Oversikt",
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableSetStatus: "Sett {status}",
editJobTitle: "Rediger jobb",
rulesTitle: "Regler for oppfølging og ghosting",
rulesBody: "Jobber får et «Følg opp»-flagg basert på disse tersklene. Ghosting skjer automatisk.",
rulesAppliedFollowUpDays: "Søkt: oppfølgingsdager",
rulesAppliedGhostDays: "Søkt: ghostingdager",
rulesOfferFollowUpDays: "Tilbud: oppfølgingsdager",
rulesOfferGhostDays: "Tilbud: ghostingdager",
rulesFeedbackFollowUpDays: "Tilbakemelding: oppfølgingsdager",
rulesFeedbackGhostDays: "Tilbakemelding: ghostingdager",
rulesSaving: "Lagrer...",
rulesSave: "Lagre regler",
},
} as const;
export type TranslationKey = keyof typeof translations.en;
+14 -13
View File
@@ -24,6 +24,7 @@ import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg";
import { useI18n } from "../i18n/I18nProvider";
export type NavItem = {
to: string;
@@ -68,7 +69,7 @@ export default function AppShell({
onNavigate: (to: string) => void;
onToggleDrawer: (open: boolean) => void;
drawerOpen: boolean;
user?: { email?: string; userName?: string; roleLabel?: string };
user?: { email?: string; userName?: string; displayName?: string; avatarImageDataUrl?: string; roleLabel?: string };
notificationsCount?: number;
onOpenNotifications?: () => void;
onOpenSettings?: () => void;
@@ -77,6 +78,7 @@ export default function AppShell({
rightActions?: React.ReactNode;
children: React.ReactNode;
}) {
const { t } = useI18n();
const drawerWidth = 254;
const grouped = useMemo(() => {
@@ -149,7 +151,7 @@ export default function AppShell({
</Typography>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Track your hunt
{t("appTagline")}
</Typography>
</Box>
@@ -165,7 +167,8 @@ export default function AppShell({
</Box>
);
const initials = initialsFrom(user?.userName || user?.email);
const nameForAvatar = user?.displayName || user?.userName || user?.email;
const initials = initialsFrom(nameForAvatar);
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const userMenuOpen = Boolean(userMenuAnchor);
@@ -194,14 +197,12 @@ export default function AppShell({
<MenuIcon fontSize="small" />
</IconButton>
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }}>
{/* Top-bar search removed (unused). */}
</Box>
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }} />
<IconButton
color="secondary"
size="small"
title="Notifications"
title={t("notifications")}
onClick={onOpenNotifications}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
@@ -212,7 +213,7 @@ export default function AppShell({
<IconButton
color="secondary"
size="small"
title="Settings"
title={t("settings")}
onClick={onOpenSettings}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
@@ -228,10 +229,10 @@ export default function AppShell({
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
>
<Avatar sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
</IconButton>
<Box sx={{ display: { xs: "none", sm: "block" } }}>
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.userName || user.email || "User"}</Typography>
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.displayName || user.userName || user.email || t("user")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{user.roleLabel || ""}
</Typography>
@@ -252,7 +253,7 @@ export default function AppShell({
onOpenProfile?.();
}}
>
Profile
{t("profileMenu")}
</MenuItem>
<MenuItem
onClick={() => {
@@ -260,7 +261,7 @@ export default function AppShell({
onOpenSettings?.();
}}
>
Settings
{t("settingsMenu")}
</MenuItem>
<Divider />
<MenuItem
@@ -269,7 +270,7 @@ export default function AppShell({
onSignOut?.();
}}
>
Sign out
{t("signOut")}
</MenuItem>
</Menu>
</Toolbar>
+62 -23
View File
@@ -7,10 +7,12 @@ import {
Chip,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type SummarizerMetrics = {
healthy: boolean;
@@ -111,10 +113,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
}
export default function AdminSystemPage() {
const { t } = useI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(false);
const [runningProbe, setRunningProbe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("Jobbjakt SMTP test");
const [testEmailMessage, setTestEmailMessage] = useState("This is a test email from the Jobbjakt system panel.");
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const load = async () => {
setLoading(true);
@@ -148,12 +155,27 @@ export default function AdminSystemPage() {
return "success" as const;
}, [status]);
const sendTestEmail = async () => {
setSendingTestEmail(true);
try {
await api.post("/users/send-test-email", {
toEmail: testEmailTo.trim() || null,
subject: testEmailSubject.trim() || null,
message: testEmailMessage.trim() || null,
});
} catch (e: any) {
setError(e?.response?.data || e?.message || "Failed to send test email.");
} finally {
setSendingTestEmail(false);
}
};
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography>
<Typography sx={{ color: "text.secondary" }}>Production diagnostics for runtime, database, auth, email, and summarizer health.</Typography>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("adminSystemTitle")}</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("adminSystemSubtitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
@@ -172,10 +194,10 @@ export default function AdminSystemPage() {
}}
disabled={loading || runningProbe}
>
{runningProbe ? "Running probe..." : "Run probe now"}
{runningProbe ? t("adminSystemRunningProbe") : t("adminSystemRunProbe")}
</Button>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
{loading ? t("adminSystemRefreshing") : t("adminSystemRefresh")}
</Button>
</Box>
</Box>
@@ -186,37 +208,37 @@ export default function AdminSystemPage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<SummaryCard
title="Environment"
title={t("adminSystemEnvironment")}
value={status?.environment ?? "-"}
subtitle={`Version ${displayMetadata(status?.version)} · Commit ${displayMetadata(status?.commitSha)}`}
/>
<SummaryCard
title="Database"
value={status ? (status.database.canConnect ? "Connected" : "Offline") : "-"}
title={t("adminSystemDatabase")}
value={status ? (status.database.canConnect ? t("adminSystemConnected") : t("adminSystemOffline")) : "-"}
subtitle={status ? `${status.database.provider} · ${status.database.target || "No target"}` : "-"}
tone={dbTone}
/>
<SummaryCard
title="SMTP"
value={status?.email.enabled ? "Enabled" : "Disabled"}
subtitle={status?.email.host || "No SMTP host configured"}
title={t("adminSystemSmtp")}
value={status?.email.enabled ? t("adminSystemEnabled") : t("adminSystemDisabled")}
subtitle={status?.email.host || t("adminSystemNoSmtpHost")}
tone={status?.email.enabled ? "success" : "default"}
/>
<SummaryCard
title="Summarizer"
value={status?.summarizer.healthy ? "Healthy" : "Offline"}
title={t("adminSystemSummarizer")}
value={status?.summarizer.healthy ? t("adminSystemHealthy") : t("adminSystemOffline")}
subtitle={status?.summarizer.probeLatencyMs != null
? `${status.summarizer.probeLatencyMs} ms probe · ${status.summarizer.device || "unknown device"}`
: status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health · ${status.summarizer.device || "unknown device"}`
: "No latency data"}
: t("adminSystemNoLatencyData")}
tone={summarizerTone}
/>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Database and storage</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Provider" value={status?.database.provider || "-"} />
<DetailRow label="Target" value={status?.database.target || "-"} />
@@ -234,7 +256,7 @@ export default function AdminSystemPage() {
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Runtime and auth</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Framework" value={status?.runtime.framework || "-"} />
<DetailRow label="OS" value={status?.runtime.osDescription || "-"} />
@@ -252,7 +274,7 @@ export default function AdminSystemPage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email configuration</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Enabled" value={status?.email.enabled ? "Yes" : "No"} />
<DetailRow label="From" value={status?.email.from || "-"} />
@@ -264,7 +286,7 @@ export default function AdminSystemPage() {
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer runtime</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Model" value={status?.summarizer.model || "-"} />
<DetailRow label="Device" value={status?.summarizer.device || "-"} />
@@ -280,7 +302,24 @@ export default function AdminSystemPage() {
</Box>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSmtpTest")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
{t("adminSystemSmtpTestBody")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label={t("adminSystemRecipientEmail")} value={testEmailTo} onChange={(e) => setTestEmailTo(e.target.value)} placeholder={t("adminSystemRecipientPlaceholder")} />
<TextField label={t("adminSystemSubject")} value={testEmailSubject} onChange={(e) => setTestEmailSubject(e.target.value)} />
<TextField label={t("adminSystemMessage")} multiline minRows={3} value={testEmailMessage} onChange={(e) => setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
<Button variant="contained" disabled={sendingTestEmail} onClick={() => void sendTestEmail()}>
{sendingTestEmail ? t("adminSystemSending") : t("adminSystemSendTestEmail")}
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(6, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography></Box>
@@ -290,11 +329,11 @@ export default function AdminSystemPage() {
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography></Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Chip label={status?.database.canConnect ? "Database connected" : "Database issue"} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Chip label={status?.auth.required ? "Auth enforced" : "Auth optional"} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? "Google sign-in ready" : "Google sign-in off"} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? "Gmail ready" : "Gmail incomplete"} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? "GPU visible" : "CPU mode"} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Chip label={status?.auth.required ? t("adminSystemAuthEnforced") : t("adminSystemAuthOptional")} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? t("adminSystemGoogleReady") : t("adminSystemGoogleOff")} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
</Box>
</Paper>
</Box>
+26 -63
View File
@@ -19,6 +19,7 @@ import {
import { api } from "../api";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
type UserDto = {
id: string;
@@ -31,6 +32,7 @@ type UserDto = {
export default function AdminUsersPage() {
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const { t } = useI18n();
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(false);
@@ -38,11 +40,6 @@ export default function AdminUsersPage() {
const [newPassword, setNewPassword] = useState("");
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("Job Tracker SMTP test");
const [testEmailMessage, setTestEmailMessage] = useState("This is a test email from the Job Tracker admin panel.");
const [sendingTestEmail, setSendingTestEmail] = useState(false);
async function load() {
setLoading(true);
try {
@@ -64,10 +61,10 @@ export default function AdminUsersPage() {
const setAdminRole = async (u: UserDto, isAdmin: boolean) => {
try {
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
toast("Roles updated.", "success");
toast(t("adminUsersRolesUpdated"), "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update roles.";
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
toast(String(msg), "error");
}
};
@@ -75,73 +72,39 @@ export default function AdminUsersPage() {
const sendReset = async (u: UserDto) => {
try {
await api.post(`/users/${u.id}/send-password-reset`);
toast("Password reset email sent.", "success");
toast(t("adminUsersResetSent"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send reset.";
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
toast(String(msg), "error");
}
};
const sendTestEmail = async () => {
setSendingTestEmail(true);
try {
await api.post("/users/send-test-email", {
toEmail: testEmailTo.trim() || null,
subject: testEmailSubject.trim() || null,
message: testEmailMessage.trim() || null,
});
toast("Test email sent.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send test email.";
toast(String(msg), "error");
} finally {
setSendingTestEmail(false);
}
};
const remove = async (u: UserDto) => {
if (!(await confirmAction(`Delete user ${u.email || u.userName || u.id}?`, { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
if (!(await confirmAction(`Delete user ${u.email || u.userName || u.id}?`, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");
toast(t("adminUsersDeleted"), "info");
await load();
} catch {
toast("Failed to delete user.", "error");
toast(t("adminUsersDeleteFailed"), "error");
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
Users
{t("adminUsersTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>Admin-only user management.</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography sx={{ fontWeight: 900, mb: 1 }}>SMTP test email</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
Send a quick delivery check using the configured SMTP settings. Leave the recipient blank to use your admin email.
</Typography>
<Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label="Recipient email" value={testEmailTo} onChange={(e) => setTestEmailTo(e.target.value)} placeholder="Uses your admin email if left blank" />
<TextField label="Subject" value={testEmailSubject} onChange={(e) => setTestEmailSubject(e.target.value)} />
<TextField label="Message" multiline minRows={3} value={testEmailMessage} onChange={(e) => setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
<Button variant="contained" disabled={sendingTestEmail} onClick={() => void sendTestEmail()}>
{sendingTestEmail ? "Sending..." : "Send test email"}
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography sx={{ fontWeight: 900, mb: 1 }}>Create user</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<TextField label="Password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</Box>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label="Admin" />
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
<Button
variant="contained"
disabled={!canCreate || loading}
@@ -151,15 +114,15 @@ export default function AdminUsersPage() {
setNewEmail("");
setNewPassword("");
setNewIsAdmin(false);
toast("User created.", "success");
toast(t("adminUsersCreated"), "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to create user.";
const msg = e?.response?.data || e?.message || t("adminUsersCreateFailed");
toast(String(msg), "error");
}
}}
>
Create
{t("create")}
</Button>
</Box>
</Paper>
@@ -168,11 +131,11 @@ export default function AdminUsersPage() {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Username</TableCell>
<TableCell>{t("profileEmail")}</TableCell>
<TableCell>{t("profileUsername")}</TableCell>
<TableCell>Roles</TableCell>
<TableCell>Confirmed</TableCell>
<TableCell align="right">Actions</TableCell>
<TableCell>{t("adminUsersConfirmed")}</TableCell>
<TableCell align="right">{t("adminUsersActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -187,13 +150,13 @@ export default function AdminUsersPage() {
<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)}>
Admin
{t("adminUsersAdmin")}
</Button>
<Button size="small" variant="outlined" onClick={() => void sendReset(u)}>
Send reset
{t("adminUsersSendReset")}
</Button>
<Button size="small" color="error" variant="outlined" onClick={() => void remove(u)}>
Delete
{t("adminUsersDelete")}
</Button>
</Box>
</TableCell>
@@ -204,7 +167,7 @@ export default function AdminUsersPage() {
{!loading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No users.</Typography>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
</TableCell>
</TableRow>
) : null}
+16 -48
View File
@@ -8,6 +8,7 @@ import { api } from "../api";
import { setAuthToken } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type AuthConfig = {
requireAuth: boolean;
@@ -18,6 +19,7 @@ type AuthConfig = {
export default function LoginPage() {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation() as any;
@@ -41,15 +43,12 @@ export default function LoginPage() {
setLoading(true);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
const res = await api.post<{ accessToken: string; tokenType: string }>(url, {
email,
password,
});
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
setAuthToken(res.data.accessToken);
toast("Signed in.", "success");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Login failed.";
const msg = e?.response?.data || e?.message || t("loginFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
@@ -72,67 +71,36 @@ export default function LoginPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Sign in
{t("signInTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{cfg?.requireAuth ? "Authentication is required to use this app." : "Authentication is optional in this environment."}
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Email & password" />
<Tab label="Google" />
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
</Tabs>
{tab === 0 && (
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
void submit("login");
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<TextField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
fullWidth
/>
<TextField
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={allowReg ? "new-password" : "current-password"}
type="password"
fullWidth
/>
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{allowReg && (
<Button
type="button"
variant="outlined"
disabled={loading}
onClick={() => void submit("register")}
>
Create account
<Button type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disabled={loading}>
Sign in
{t("signInTitle")}
</Button>
</Box>
</Box>
)}
{tab === 1 && (
<GoogleAuthCard
onSignedIn={() => {
navigate(nextPath, { replace: true });
}}
/>
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper>
</Box>
);
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
export default function NotFoundPage() {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Paper sx={{ p: { xs: 3, md: 5 }, borderRadius: 4 }}>
<Box sx={{ display: "grid", gap: 1.5, maxWidth: 560 }}>
<Typography variant="overline" sx={{ color: "text.secondary", letterSpacing: 1.6 }}>
404
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{t("notFoundTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{t("notFoundBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/jobs")}>
{t("goHome")}
</Button>
<Button variant="outlined" onClick={() => navigate(-1)}>
{t("goBack")}
</Button>
</Box>
</Box>
</Paper>
);
}
+133 -43
View File
@@ -2,9 +2,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import { api } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type MeResponse = {
provider?: "local" | "google" | "external";
@@ -15,6 +20,7 @@ type MeResponse = {
lastName?: string;
displayName?: string;
profileCvText?: string;
avatarImageDataUrl?: string;
roles?: string[];
googleLink?: {
linked: boolean;
@@ -23,6 +29,9 @@ type MeResponse = {
} | null;
};
const CV_UPLOAD_ACCEPT = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
@@ -36,10 +45,15 @@ function initialsFrom(values: Array<string | undefined>) {
export default function ProfilePage() {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { t } = useI18n();
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
@@ -77,23 +91,102 @@ export default function ProfilePage() {
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession");
const googleLabel = me?.googleLink?.linked
? me.googleLink.email
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
: t("profileGoogleLinked")
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
<CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
setCropOpen(false);
setAvatarFile(null);
}}
onSave={async (blob) => {
const file = new File([blob], "avatar.png", { type: "image/png" });
const formData = new FormData();
formData.append("file", file);
setUploadingAvatar(true);
try {
const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev));
setCropOpen(false);
setAvatarFile(null);
toast(t("profileImageUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
/>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Avatar sx={{ width: 64, height: 64, fontWeight: 900, fontSize: 24 }}>{initials}</Avatar>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
<input
ref={avatarInputRef}
type="file"
accept={AVATAR_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
event.target.value = "";
if (!file) return;
setAvatarFile(file);
setCropOpen(true);
}}
/>
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
</Button>
{me?.avatarImageDataUrl ? (
<Button
variant="text"
size="small"
color="inherit"
startIcon={<DeleteOutlineIcon />}
disabled={!isLocal || uploadingAvatar}
onClick={async () => {
setUploadingAvatar(true);
try {
await api.delete("/auth/avatar");
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
toast(t("profileImageRemoved"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
>
{t("profileRemoveImage")}
</Button>
) : null}
</Box>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile
{t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.displayName || fullName || me?.userName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || "Add a short headline to personalize your account view."}</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
<Chip label={me?.provider === "local" ? "Local account" : me?.provider === "google" ? "Google session" : "External session"} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={me?.googleLink?.linked ? `Google linked${me.googleLink.email ? `: ${me.googleLink.email}` : ""}` : "Google not linked"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={profileCvText.trim() ? `CV ready · ${cvWordCount} words` : "CV missing"} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
@@ -101,40 +194,40 @@ export default function ProfilePage() {
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">Account</Typography>
<Typography variant="h6">{t("profileAccountSection")}</Typography>
{!isLocal ? (
<Alert severity="info" sx={{ mt: 1 }}>
This session is not using a local app token, so profile edits are read-only right now.
{t("profileReadOnlyInfo")}
</Alert>
) : null}
</Box>
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileUsername")} value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileFirstName")} value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileLastName")} value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
<TextField
label="Profile headline"
label={t("profileHeadline")}
value={headline}
onChange={(e) => setHeadline(e.target.value)}
helperText="Stored only in this browser to personalize your workspace."
helperText={t("profileHeadlineHelp")}
fullWidth
/>
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">Master CV</Typography>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging.
{t("profileMasterCvBody")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<input
ref={fileInputRef}
ref={cvInputRef}
type="file"
accept=".txt,.md,text/plain,text/markdown"
accept={CV_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={async (event) => {
const file = event.target.files?.[0];
@@ -146,28 +239,28 @@ export default function ProfilePage() {
try {
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast("CV text imported.", "success");
toast(t("profileCvUploaded"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
setUploadingCv(false);
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
{uploadingCv ? "Importing..." : "Import .txt/.md"}
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
Copy CV text
{t("profileCopyCvText")}
</Button>
</Box>
</Box>
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
<TextField
label="Profile CV / master resume text"
label={t("profileCvTextLabel")}
value={profileCvText}
onChange={(e) => setProfileCvText(e.target.value)}
helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next."
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
@@ -178,15 +271,12 @@ export default function ProfilePage() {
{cvWordCount} words
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Tip: plain text works best right now.
{t("profileCvPreferredUploads")}
</Typography>
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
</Typography>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Button
variant="contained"
disabled={!isLocal || loading}
@@ -196,27 +286,27 @@ export default function ProfilePage() {
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
window.localStorage.setItem("profileHeadline", headline.trim());
await loadProfile();
toast("Profile updated.", "success");
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile.";
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Save changes
{t("profileSaveChanges")}
</Button>
</Box>
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="h6">Change password</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>Password changes are only available for local accounts.</Typography> : null}
<Typography variant="h6">{t("profileChangePassword")}</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
</Box>
<TextField label="Current password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="New password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
@@ -228,16 +318,16 @@ export default function ProfilePage() {
await api.post("/auth/change-password", { currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
toast("Password updated.", "success");
toast(t("profilePasswordUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to change password.";
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Update password
{t("profileUpdatePassword")}
</Button>
</Box>
</Box>
+11 -10
View File
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
function useQuery() {
const { search } = useLocation();
@@ -14,6 +15,7 @@ function useQuery() {
export default function ResetPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
const q = useQuery();
@@ -37,10 +39,10 @@ export default function ResetPasswordPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Reset password
{t("resetPasswordTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Set a new password for your account.
{t("resetPasswordBody")}
</Typography>
<Box
@@ -48,33 +50,33 @@ export default function ResetPasswordPage() {
onSubmit={(e) => {
e.preventDefault();
if (!email || !token) {
toast("Missing email/token in link.", "error");
toast(t("missingResetLinkInfo"), "error");
return;
}
setLoading(true);
api
.post("/auth/reset-password", { email, token, newPassword })
.then(() => {
toast("Password reset. Please sign in.", "success");
toast(t("passwordResetSuccess"), "success");
navigate("/login", { replace: true });
})
.catch((e2: any) => {
const msg = e2?.response?.data || e2?.message || "Reset failed.";
const msg = e2?.response?.data || e2?.message || t("resetFailed");
toast(String(msg), "error");
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<TextField label="Email" value={email} disabled fullWidth />
<TextField label="New password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
<TextField label={t("profileEmail")} value={email} disabled fullWidth />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
Back to login
{t("backToLogin")}
</Button>
<Button type="submit" variant="contained" disabled={loading}>
Update password
{t("updatePassword")}
</Button>
</Box>
</Box>
@@ -82,4 +84,3 @@ export default function ResetPasswordPage() {
</Box>
);
}
@@ -0,0 +1,43 @@
import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate, useRouteError } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
export default function RouteErrorPage() {
const navigate = useNavigate();
const error = useRouteError() as any;
const { t } = useI18n();
const details = typeof error?.statusText === "string" && error.statusText.trim()
? error.statusText
: typeof error?.message === "string" && error.message.trim()
? error.message
: null;
return (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", p: 3 }}>
<Paper sx={{ p: { xs: 3, md: 5 }, borderRadius: 4, width: "100%", maxWidth: 640 }}>
<Box sx={{ display: "grid", gap: 1.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary", letterSpacing: 1.6 }}>
{error?.status || 500}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{t("appErrorTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{t("appErrorBody")}
</Typography>
{details ? <Typography sx={{ color: "text.secondary" }}>{details}</Typography> : null}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/jobs")}>
{t("goHome")}
</Button>
<Button variant="outlined" onClick={() => navigate(-1)}>
{t("goBack")}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
}
+35 -14
View File
@@ -24,7 +24,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(accentColor || "#606BDF"),
primary: buildPrimary(accentColor || "#15803D"),
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
@@ -47,11 +47,11 @@ function buildLightPalette(accentColor: string): PaletteLike {
darker: "#4A2800",
},
success: {
lighter: "#C8FFC0",
light: "#B6F2AF",
main: "#22892F",
dark: "#006E1C",
darker: "#00390A",
lighter: "#DCFCE7",
light: "#BBF7D0",
main: "#16A34A",
dark: "#15803D",
darker: "#14532D",
},
info: {
lighter: "#D4F7FF",
@@ -80,7 +80,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
divider,
background: { default: background, paper: background },
action: {
hover: alpha(secondaryMain, 0.05),
hover: alpha(accentColor || "#15803D", 0.05),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
@@ -99,7 +99,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(accentColor || "#606BDF"),
primary: buildPrimary(accentColor || "#15803D"),
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
@@ -122,8 +122,8 @@ function buildDarkPalette(accentColor: string): PaletteLike {
darker: "#FFE1B8",
},
success: {
lighter: alpha("#22892F", 0.18),
light: alpha("#22892F", 0.12),
lighter: alpha("#16A34A", 0.18),
light: alpha("#16A34A", 0.12),
main: "#4ADE80",
dark: "#22C55E",
darker: "#BBF7D0",
@@ -155,7 +155,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider,
background: { default: bg, paper },
action: {
hover: alpha("#FFFFFF", 0.06),
hover: alpha(accentColor || "#15803D", 0.16),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
@@ -189,7 +189,6 @@ function buildTypography() {
body2: { fontWeight: 400, fontSize: 13, lineHeight: "17px" },
caption: { fontWeight: 400, fontSize: 12, lineHeight: "16px", letterSpacing: 0 },
overline: { fontWeight: 600, fontSize: 11, lineHeight: "14px", letterSpacing: "0.08em", textTransform: "uppercase" as const },
// Saasable uses caption1; keep as a custom variant for internal usage if needed.
caption1: { fontWeight: 500, fontSize: 12, lineHeight: "16px", letterSpacing: 0 },
button: { textTransform: "capitalize" as const },
};
@@ -277,8 +276,30 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
},
}),
notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.divider }),
multiline: { padding: 10 },
input: { paddingLeft: 0, paddingRight: 0 },
multiline: {
padding: 10,
alignItems: "flex-start",
},
input: {
paddingLeft: 0,
paddingRight: 0,
paddingTop: 10,
paddingBottom: 10,
},
inputMultiline: {
paddingTop: 0,
paddingBottom: 0,
lineHeight: 1.5,
},
},
},
MuiInputBase: {
styleOverrides: {
inputMultiline: {
"&::placeholder": {
opacity: 0.72,
},
},
},
},
MuiListItemButton: {
+1 -1
View File
@@ -40,7 +40,7 @@ export function setThemeModePref(v: ThemeModePref) {
export function getAccentColor(): string {
const raw = window.localStorage.getItem(k("accentColor"));
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return "#7c4dff";
return "#15803d";
}
export function setAccentColor(v: string) {