acf60c2a07
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while keeping the app's actual routing/rendering model unchanged -- the app is almost entirely behind auth with no proven SSR/SEO need, so a real App Router rewrite would touch ~90 files for zero user-visible benefit. - next.config.js: output:'export' (static HTML+JS, same "single index.html served by nginx with try_files fallback" deploy as CRA). - app/layout.tsx + app/page.tsx: root shell ports public/index.html's <head>, mounts the whole existing App tree client-only (ssr:false) since it reads window/localStorage during initial render and Next's static prerender would otherwise execute that on the server. - Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects any `pages/` dir under the app root and tried to build our React Router page components as its own routes). - REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development, Dockerfile, docker-compose.yml build args. - Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported under Turbopack) with a small inline JobbjaktMark component. - TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax 4.9's parser rejects; CRA never hit this because babel doesn't type-check). - Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts, public/index.html); kept react-scripts as the Jest test runner only (next/jest migration not needed -- the existing config already works). Verified: `next build` static export succeeds, `next dev` serves the landing page and client-side routes (login etc.) correctly, all 57 frontend tests + 172 backend tests still green. Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s in `next dev` since there's no server route for it -- the app only ever mounts at "/". Production is unaffected: nginx's existing try_files fallback still serves index.html for any path.
145 lines
6.4 KiB
TypeScript
145 lines
6.4 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Box,
|
|
Chip,
|
|
CircularProgress,
|
|
FormControl,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
Button,
|
|
} from "@mui/material";
|
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { useToast } from "../toast";
|
|
|
|
export type CorrespondenceInboxItem = {
|
|
id: number;
|
|
jobApplicationId: number;
|
|
companyName?: string | null;
|
|
jobTitle?: string | null;
|
|
from: string;
|
|
direction?: string | null;
|
|
subject?: string | null;
|
|
channel?: string | null;
|
|
date: string;
|
|
contentPreview: string;
|
|
externalThreadId?: string | null;
|
|
externalFrom?: string | null;
|
|
externalTo?: string | null;
|
|
labelCount: number;
|
|
attachmentCount: number;
|
|
};
|
|
|
|
export default function CorrespondenceInboxPage() {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [direction, setDirection] = useState<string>("all");
|
|
const [linkState, setLinkState] = useState<string>("all");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
|
params: {
|
|
q: query.trim() || undefined,
|
|
direction: direction === "all" ? undefined : direction,
|
|
linkState: linkState === "all" ? undefined : linkState,
|
|
},
|
|
});
|
|
setItems(res.data ?? []);
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [direction, linkState, query, toast]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const filteredSummary = useMemo(() => {
|
|
const linked = items.filter((item) => item.externalThreadId).length;
|
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
|
return { linked, inbound };
|
|
}, [items]);
|
|
|
|
return (
|
|
<Paper sx={{ mt: 0, p: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>Correspondence inbox</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
Cross-job view of imported correspondence and Gmail-linked history.
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
|
|
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
|
|
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
|
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
|
<FormControl fullWidth>
|
|
<InputLabel>Direction</InputLabel>
|
|
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
|
<MenuItem value="all">All</MenuItem>
|
|
<MenuItem value="inbound">Inbound</MenuItem>
|
|
<MenuItem value="outbound">Outbound</MenuItem>
|
|
<MenuItem value="internal">Internal</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Link state</InputLabel>
|
|
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
|
<MenuItem value="all">All</MenuItem>
|
|
<MenuItem value="linked">Linked threads</MenuItem>
|
|
<MenuItem value="manual">Manual/internal only</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
|
</Box>
|
|
|
|
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
|
|
|
{!loading && items.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
|
|
) : null}
|
|
|
|
<Stack spacing={1.25}>
|
|
{items.map((item) => (
|
|
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
|
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
|
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
|
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
|
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
|
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
|
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|