feat(projects): kind on create/admin, home page grouped by client
This commit is contained in:
parent
5dae0946eb
commit
140703e0eb
4 changed files with 117 additions and 39 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
123
src/app/page.tsx
123
src/app/page.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue