src/components/me/roles-panel.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import {
ArrowRightIcon,
BriefcaseIcon,
CircleHelpIcon,
DownloadIcon,
LockIcon,
MapPinIcon,
MessageSquareWarningIcon,
PlusIcon,
SparklesIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/page-header";
import { Lift, Stagger, StaggerItem } from "@/components/motion";
import { useAsk } from "@/components/assistant/ask";
import { cn, dateRange, truncate } from "@/lib/utils";
import { GROUP_LABEL, GROUP_ORDER, roleGroup } from "@/lib/role-groups";
import { resolveOpenQuestionAction } from "@/server/actions";
type RoleCard = {
id: string;
company: string;
title: string;
location: string;
employmentType: string;
startDate: string;
endDate: string;
isCurrent: boolean;
summary: string;
tags: string[];
backgroundLength: number;
highlightCount: number;
/** An evidence-only preview, for a role with no scope summary. */
excerpt: string;
rules: number;
caveats: number;
open: number;
/** Days since anything was written into the background. */
daysSinceAdded: number;
};
type OpenQuestion = {
roleId: string | null;
role: string;
question: string;
kind: "background" | "start_date" | "end_date";
};
/** A current job with nothing added for this long gets a nudge on its card. */
const STALE_DAYS = 30;
export function RolesPanel({
roles,
openQuestions,
}: {
roles: RoleCard[];
openQuestions: OpenQuestion[];
}) {
if (roles.length === 0) {
return (
<EmptyState
icon={BriefcaseIcon}
title="Nothing on file yet"
description="Paste in a resume you already have and it fills this in for you — jobs, dates, bullets. Or add one job by hand and dump everything you remember about it underneath."
action={
// Links rather than the dialogs themselves: both are already mounted
// by the page above and open on a URL parameter, which is what the
// setup strip links to as well. One button that works two ways beats
// a second copy of a dialog.
<div className="flex flex-wrap justify-center gap-2">
<Button asChild>
<Link href="/me?import=1">
<DownloadIcon /> Paste a resume
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/me?new=role">
<PlusIcon /> Add a job by hand
</Link>
</Button>
</div>
}
/>
);
}
// Grouped by kind of role when there is more than one kind: three advisory
// seats listed between two jobs read as job-hopping; under their own
// heading they read as what they are.
const groups = GROUP_ORDER.map((group) => ({
group,
roles: roles.filter((role) => roleGroup(role.employmentType) === group),
})).filter((entry) => entry.roles.length > 0);
return (
<div className="space-y-6">
{openQuestions.length > 0 && <OpenQuestions questions={openQuestions} />}
{groups.map(({ group, roles: members }) => (
<section key={group} className="space-y-3">
{groups.length > 1 && (
<h2 className="text-muted-foreground text-[12px] font-medium tracking-wide uppercase">
{GROUP_LABEL[group]}
<span className="ml-1.5 tabular-nums">{members.length}</span>
</h2>
)}
<RoleGrid roles={members} />
</section>
))}
</div>
);
}
function RoleGrid({ roles }: { roles: RoleCard[] }) {
const ask = useAsk();
return (
<Stagger className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{roles.map((role) => {
const stale = role.isCurrent && role.daysSinceAdded >= STALE_DAYS;
return (
<StaggerItem key={role.id}>
<Lift>
<Link href={`/me/${role.id}`} className="block h-full">
<Card className="group hover:shadow-raised relative h-full overflow-hidden transition-shadow duration-200 ease-[var(--ease-settle)]">
<CardContent className="relative flex h-full flex-col pt-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold tracking-tight">{role.title}</div>
<div className="text-muted-foreground truncate text-sm font-medium">{role.company}</div>
</div>
{role.isCurrent && (
<Badge variant="success" className="shrink-0">
Current
</Badge>
)}
</div>
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>{dateRange(role.startDate, role.endDate, role.isCurrent)}</span>
{role.location && (
<span className="flex items-center gap-1">
<MapPinIcon className="size-3" />
{role.location}
</span>
)}
</div>
{(role.summary || role.excerpt) && (
<p className="text-muted-foreground mt-3 line-clamp-2 text-sm leading-relaxed">
{truncate(role.summary || role.excerpt, 130)}
</p>
)}
{role.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{role.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
</div>
)}
{stale && (
// The job you are in is the one whose details are
// freshest now and gone by the time a resume is due.
<div className="bg-warning-tint mt-3 flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-xs">
<span>Nothing added in {Math.floor(role.daysSinceAdded / 7)} weeks.</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
ask(
`Help me log what I've done lately in my ${role.title} role at ${role.company} (role id ${role.id}). Ask me what I worked on, shipped and learned since I last added to it, then file my answers with append_role_background. Don't invent anything I didn't say.`,
);
}}
className="text-primary shrink-0 font-medium hover:underline"
>
Log recent work
</button>
</div>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-5 text-xs">
<span className="flex items-center gap-1.5">
<SparklesIcon className="text-muted-foreground size-3" />
<span className="font-medium tabular-nums">{role.highlightCount}</span>
<span className="text-muted-foreground">
{role.highlightCount === 1 ? "highlight" : "highlights"}
</span>
</span>
<span className="text-muted-foreground tabular-nums">
{formatBackground(role.backgroundLength)}
</span>
{/* What the background carries besides evidence, so a rule
or an unsettled fact shows from the list rather than
only once you open the role. */}
<Marks role={role} />
<ArrowRightIcon className="text-muted-foreground ml-auto size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</CardContent>
</Card>
</Link>
</Lift>
</StaggerItem>
);
})}
</Stagger>
);
}
function Marks({ role }: { role: RoleCard }) {
const marks = [
{ count: role.rules, icon: LockIcon, key: "rules", className: "text-muted-foreground" },
{ count: role.caveats, icon: MessageSquareWarningIcon, key: "caveats", className: "text-muted-foreground" },
{ count: role.open, icon: CircleHelpIcon, key: "open", className: "text-warning" },
].filter((mark) => mark.count > 0);
if (marks.length === 0) return null;
const title: Record<string, (n: number) => string> = {
rules: (n) => `${n} ${n === 1 ? "rule" : "rules"} on how this job is described`,
caveats: (n) => `${n} ${n === 1 ? "caveat" : "caveats"}, never written into a document`,
open: (n) => `${n} not settled yet, kept off documents until you answer`,
};
return (
<span className="flex items-center gap-2.5">
{marks.map(({ count, icon: Icon, key, className }) => (
<span
key={key}
title={title[key](count)}
className={cn("flex items-center gap-1 tabular-nums", className)}
>
<Icon className="size-3" />
{count}
{key === "open" && " open"}
</span>
))}
</span>
);
}
/**
* What they have marked as not settled, across every role and the profile,
* with the answer box right there. Answering puts the fact in as evidence
* where the doubt was; a date is confirmed or corrected; "Drop" is for a
* question that no longer matters. resolve_open_question is the same write.
*/
function OpenQuestions({ questions }: { questions: OpenQuestion[] }) {
const [shownAll, setShownAll] = useState(false);
const shown = shownAll ? questions : questions.slice(0, 6);
return (
<Card className="border-warning/40 border-dashed">
<CardContent className="space-y-3 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<div className="flex items-center gap-2">
<CircleHelpIcon className="text-warning size-4" />
<h2 className="text-[15px] font-semibold tracking-tight">
{questions.length === 1 ? "One thing to settle" : `${questions.length} things to settle`}
</h2>
</div>
<p className="text-muted-foreground text-xs">
Kept off every document until you answer. Your answer goes in where the question was.
</p>
</div>
<ul className="divide-border divide-y">
{shown.map((item) => (
<QuestionRow key={`${item.roleId}:${item.question}`} item={item} />
))}
</ul>
{questions.length > 6 && (
<button
type="button"
onClick={() => setShownAll((value) => !value)}
className="text-muted-foreground hover:text-foreground text-xs"
>
{shownAll ? "Show fewer" : `Show all ${questions.length}`}
</button>
)}
</CardContent>
</Card>
);
}
function QuestionRow({ item }: { item: OpenQuestion }) {
const [answering, setAnswering] = useState(false);
const [answer, setAnswer] = useState("");
const [pending, startTransition] = useTransition();
const [gone, setGone] = useState(false);
const isDate = item.kind !== "background";
const settle = (reply?: string) =>
startTransition(async () => {
try {
await resolveOpenQuestionAction(item.roleId, item.question, reply);
setGone(true);
toast.success(isDate ? "Date confirmed." : reply ? "Answered and filed." : "Dropped.");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not save that.");
}
});
if (gone) return null;
return (
<li className="py-2">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:gap-3">
{item.roleId ? (
<Link
href={`/me/${item.roleId}`}
className="text-muted-foreground hover:text-foreground shrink-0 truncate pt-px text-xs sm:w-44"
>
{item.role}
</Link>
) : (
<Link
href="/me?tab=profile"
className="text-muted-foreground hover:text-foreground shrink-0 truncate pt-px text-xs sm:w-44"
>
Profile
</Link>
)}
<span className="min-w-0 flex-1 text-sm leading-snug">{truncate(item.question, 200)}</span>
{!answering && (
<span className="flex shrink-0 gap-1">
{isDate ? (
<>
<Button variant="outline" size="sm" className="h-7 text-xs" disabled={pending} onClick={() => settle()}>
It’s right
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setAnswering(true)}>
Correct it
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setAnswering(true)}>
Answer
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground h-7 text-xs"
disabled={pending}
onClick={() => settle()}
title="Take the question away without adding anything"
>
Drop
</Button>
</>
)}
</span>
)}
</div>
{answering && (
<form
className="mt-2 flex gap-2 sm:pl-47"
onSubmit={(event) => {
event.preventDefault();
if (answer.trim()) settle(answer.trim());
}}
>
<Input
autoFocus
type={isDate ? "month" : "text"}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
placeholder={isDate ? "" : "The answer, written as a fact: “The budget was $40K a month.”"}
className="h-9 md:h-8"
/>
<Button type="submit" size="sm" disabled={pending || !answer.trim()}>
Save
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setAnswering(false)}>
Cancel
</Button>
</form>
)}
</li>
);
}
function formatBackground(length: number) {
if (length === 0) return "nothing written yet";
const words = Math.round(length / 5.5);
if (words < 1000) return `${words} words`;
return `${(words / 1000).toFixed(1)}k words`;
}