style(ui): redesign Dashboard and Kanban to match mockups
CI and Deploy / test (push) Successful in 2m16s
CI and Deploy / deploy (push) Successful in 42s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-13 09:05:54 +02:00
parent 1ab92e5c9d
commit 81512db1cb
3 changed files with 358 additions and 224 deletions
+115 -41
View File
@@ -4,6 +4,7 @@ import {
Box,
Card,
CardContent,
Chip,
IconButton,
Menu,
MenuItem,
@@ -36,6 +37,36 @@ function toneColor(theme: any, status: Status | "Other"): string {
return TONE_PALETTE[statusTone(status)](theme);
}
// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
function parseTags(raw?: string): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(0, 2) : [];
} catch {
return [];
}
}
type PillTone = "error" | "warning" | "success" | "info";
// Mockup's single status pill per card ("Reply due in 2d", "Tomorrow 14:00", "720k NOK / yr").
// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { text: string; tone: PillTone } | null {
if (job.needsFollowUp) {
const due = job.followUpAt ? new Date(job.followUpAt) : null;
if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
const days = Math.max(1, Math.ceil((due.getTime() - Date.now()) / 86400000));
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
}
if (job.salary) return { text: job.salary, tone: "success" };
if (job.deadline) return { text: new Date(job.deadline).toLocaleDateString(), tone: "info" };
return null;
}
export default function KanbanBoard() {
const theme = useTheme();
const { t } = useI18n();
@@ -129,7 +160,7 @@ export default function KanbanBoard() {
scrollSnapAlign: { xs: "start", md: "none" },
border: "1px solid",
borderColor: "divider",
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
backgroundColor: theme.palette.grey[100],
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
@@ -139,50 +170,93 @@ export default function KanbanBoard() {
{statusLabel(t, status)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 700 }}>
{list.length}
</Typography>
<Box
sx={{
minWidth: 22,
textAlign: "center",
px: 0.75,
py: 0.15,
borderRadius: 999,
backgroundColor: theme.palette.grey[300],
}}
>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
{list.length}
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{list.map((j) => (
<Card
key={j.id}
draggable
onDragStart={() => setDragJobId(j.id)}
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 2.5,
borderLeft: `4px solid ${c}`,
boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{j.jobTitle}
{list.map((j) => {
const pill = cardPill(j, t);
const tags = parseTags(j.tags);
return (
<Card
key={j.id}
draggable
onDragStart={() => setDragJobId(j.id)}
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 2.5,
borderLeft: `4px solid ${c}`,
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{j.jobTitle}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
setMenuAnchor(e.currentTarget);
}}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
setMenuAnchor(e.currentTarget);
}}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
{j.daysSince}d
</Typography>
</CardContent>
</Card>
))}
{tags.length > 0 && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
{tags.map((tag) => (
<Chip
key={tag}
size="small"
label={tag}
sx={{ height: 22, backgroundColor: alpha(theme.palette.primary.main, 0.12), color: "primary.main", fontWeight: 700 }}
/>
))}
</Box>
)}
{pill && (
<Box sx={{ mt: 0.75 }}>
<Chip
size="small"
label={pill.text}
sx={{
height: 24,
fontWeight: 700,
backgroundColor: alpha(theme.palette[pill.tone].main, 0.14),
color: theme.palette[pill.tone].main,
}}
/>
</Box>
)}
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
{t("kanbanAppliedAgo", { days: j.daysSince })}
</Typography>
</CardContent>
</Card>
);
})}
{list.length === 0 && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
{t("kanbanDropHere")}