feat(projects): kind on create/admin, home page grouped by client

This commit is contained in:
Dave 2026-09-17 10:42:48 +02:00
parent 5dae0946eb
commit 140703e0eb
4 changed files with 117 additions and 39 deletions

View file

@ -33,6 +33,7 @@ export async function createProject(input: {
repoOwner: string;
repoName: string;
color: string;
kind?: ProjectRecord["kind"];
}): Promise<{ id: string }> {
await requireOwner();
const pb = await getServerPb();
@ -51,6 +52,7 @@ export async function createProject(input: {
slug: uniqueSlug(slugify(name), siblings.map((p) => p.slug)),
description: input.description.slice(0, 20000),
status: "active",
kind: input.kind ?? "project",
color: input.color || "#2f6dad",
repo_owner: input.repoOwner.trim(),
repo_name: input.repoName.trim(),

View file

@ -21,7 +21,7 @@ export default async function AdminProjectsPage() {
<NewProjectForm clients={clients} />
<AdminTable
headers={["Project", "Client", "Repository", "Cards", "Status"]}
headers={["Project", "Client", "Repository", "Kind", "Cards", "Status"]}
empty="No projects yet."
>
{rows.map(({ project, client, cardCount }) => (
@ -50,6 +50,13 @@ export default async function AdminProjectsPage() {
<span className="text-xs text-text-muted">Not linked</span>
)}
</AdminCell>
<AdminCell>
{project.kind === "prototype" ? (
<Pill tone="accent">Prototype</Pill>
) : (
<span className="text-xs text-text-muted">Project</span>
)}
</AdminCell>
<AdminCell className="tabular">{cardCount}</AdminCell>
<AdminCell>
<Pill tone={project.status === "active" ? "brand" : "neutral"}>

View file

@ -6,13 +6,38 @@ import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { Pill } from "@/components/ui/pill";
import { getSession } from "@/lib/auth/session";
import { loadProjectSummaries } from "@/lib/data/board";
import { loadProjectSummaries, type ProjectSummary } from "@/lib/data/board";
import type { ClientRecord } from "@/lib/pb/types";
interface ClientGroup {
client: ClientRecord;
summaries: ProjectSummary[];
}
/** Preserves the incoming (project-name) order within each client's group. */
function groupByClient(summaries: ProjectSummary[]): ClientGroup[] {
const groups = new Map<string, ClientGroup>();
for (const summary of summaries) {
const group = groups.get(summary.client.id) ?? {
client: summary.client,
summaries: [],
};
group.summaries.push(summary);
groups.set(summary.client.id, group);
}
return [...groups.values()].sort((a, b) =>
a.client.name.localeCompare(b.client.name)
);
}
export default async function HomePage() {
const session = await getSession();
if (!session) redirect("/login");
const summaries = await loadProjectSummaries();
const groups = groupByClient(summaries);
return (
<AppShell role={session.role} currentPath="/">
@ -26,7 +51,7 @@ export default async function HomePage() {
/>
<div className="flex-1 overflow-y-auto p-6">
{summaries.length === 0 ? (
{groups.length === 0 ? (
<div className="mx-auto mt-16 max-w-sm text-center">
<h2 className="text-base font-medium">No projects yet</h2>
<p className="mt-1.5 text-sm text-text-muted">
@ -36,47 +61,71 @@ export default async function HomePage() {
</p>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{summaries.map(({ project, client, openCards, inboxCards }) => (
<li key={project.id}>
<Link
href={`/p/${client.slug}/${project.slug}`}
className="group block rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4 transition-colors hover:border-border-strong"
>
<div className="flex items-start gap-3">
<div className="space-y-8">
{groups.map((group) => (
<section key={group.client.id} className="space-y-3">
{/* Only the owner sees more than one group — a client manager's
own name over their own projects would be noise. */}
{session.role === "owner" && (
<div className="flex items-center gap-2">
<span
aria-hidden
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: client.brand_color || "var(--brand)" }}
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: group.client.brand_color || "var(--brand)" }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{project.name}
</p>
<p className="truncate text-xs text-text-muted">
{client.name}
</p>
</div>
{inboxCards > 0 && (
<Pill tone="accent">
<Inbox size={11} strokeWidth={2} />
<span className="tabular">{inboxCards}</span>
</Pill>
)}
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{group.client.name}
</h2>
</div>
)}
<div className="mt-4 flex items-center gap-2">
<span className="tabular text-2xl font-semibold tracking-tight">
{openCards}
</span>
<span className="text-xs text-text-muted">
open {openCards === 1 ? "card" : "cards"}
</span>
</div>
</Link>
</li>
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{group.summaries.map(({ project, client, openCards, inboxCards }) => (
<li key={project.id}>
<Link
href={`/p/${client.slug}/${project.slug}/dashboard`}
className="group block rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4 transition-colors hover:border-border-strong"
>
<div className="flex items-start gap-3">
<span
aria-hidden
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: client.brand_color || "var(--brand)" }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{project.name}
</p>
<p className="truncate text-xs text-text-muted">
{client.name}
</p>
</div>
{project.kind === "prototype" && (
<Pill tone="accent">Prototype</Pill>
)}
{inboxCards > 0 && (
<Pill tone="accent">
<Inbox size={11} strokeWidth={2} />
<span className="tabular">{inboxCards}</span>
</Pill>
)}
</div>
<div className="mt-4 flex items-center gap-2">
<span className="tabular text-2xl font-semibold tracking-tight">
{openCards}
</span>
<span className="text-xs text-text-muted">
open {openCards === 1 ? "card" : "cards"}
</span>
</div>
</Link>
</li>
))}
</ul>
</section>
))}
</ul>
</div>
)}
</div>
</AppShell>

View file

@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
import { createProject } from "@/app/actions/projects";
import { DEFAULT_BRAND_COLOR } from "@/lib/brand-default";
import type { ClientRecord } from "@/lib/pb/types";
import type { ClientRecord, ProjectRecord } from "@/lib/pb/types";
export function NewProjectForm({ clients }: { clients: ClientRecord[] }) {
const [open, setOpen] = useState(false);
@ -14,6 +14,7 @@ export function NewProjectForm({ clients }: { clients: ClientRecord[] }) {
const [description, setDescription] = useState("");
const [repoOwner, setRepoOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [kind, setKind] = useState<ProjectRecord["kind"]>("project");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
@ -37,11 +38,13 @@ export function NewProjectForm({ clients }: { clients: ClientRecord[] }) {
repoOwner,
repoName,
color: DEFAULT_BRAND_COLOR,
kind,
});
setName("");
setDescription("");
setRepoOwner("");
setRepoName("");
setKind("project");
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not create that project");
@ -132,6 +135,23 @@ export function NewProjectForm({ clients }: { clients: ClientRecord[] }) {
</label>
</div>
<label className="block space-y-1">
<span className="text-xs text-text-muted">
Kind{" "}
<span className="text-text-muted">
(a prototype is full-featured — this only tags it)
</span>
</span>
<select
value={kind}
onChange={(event) => setKind(event.target.value as ProjectRecord["kind"])}
className={field}
>
<option value="project">Project</option>
<option value="prototype">Prototype</option>
</select>
</label>
{error && (
<p role="alert" className="text-xs text-accent">
{error}