feat: add application package generation and grouped readiness workflows

This commit is contained in:
cesnimda
2026-03-22 18:28:02 +01:00
parent f1c7c38a19
commit 9188039e9d
14 changed files with 1014 additions and 373 deletions
+71 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material";
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
@@ -14,6 +14,7 @@ type MeResponse = {
firstName?: string;
lastName?: string;
displayName?: string;
profileCvText?: string;
roles?: string[];
googleLink?: {
linked: boolean;
@@ -35,8 +36,10 @@ function initialsFrom(values: Array<string | undefined>) {
export default function ProfilePage() {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
@@ -44,6 +47,7 @@ export default function ProfilePage() {
const [lastName, setLastName] = useState("");
const [displayName, setDisplayName] = useState("");
const [headline, setHeadline] = useState("");
const [profileCvText, setProfileCvText] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -57,6 +61,7 @@ export default function ProfilePage() {
setFirstName(r.data?.firstName ?? "");
setLastName(r.data?.lastName ?? "");
setDisplayName(r.data?.displayName ?? "");
setProfileCvText(r.data?.profileCvText ?? "");
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
} catch {
setMe(null);
@@ -70,6 +75,7 @@ export default function ProfilePage() {
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
@@ -87,6 +93,7 @@ export default function ProfilePage() {
<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"} />
</Box>
</Box>
@@ -115,6 +122,67 @@ export default function ProfilePage() {
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="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.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,text/plain,text/markdown"
style={{ display: "none" }}
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingCv(true);
try {
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast("CV text imported.", "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
} finally {
setUploadingCv(false);
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
{uploadingCv ? "Importing..." : "Import .txt/.md"}
</Button>
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
Copy CV text
</Button>
</Box>
</Box>
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
<TextField
label="Profile CV / master resume text"
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."
multiline
minRows={12}
disabled={!isLocal}
fullWidth
/>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Tip: plain text works best right now.
</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"}
@@ -125,7 +193,7 @@ export default function ProfilePage() {
onClick={async () => {
setLoading(true);
try {
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
window.localStorage.setItem("profileHeadline", headline.trim());
await loadProfile();
toast("Profile updated.", "success");