Add full sprint pricing workflow and supporting infrastructure: - New sidebar navigation entry for the sprint dashboard - PocketBase category collection schema and migration script - Server API routes for sprint item claims, attachments, counts, and file proxying - Dev identity utilities and UI switcher for multi-user testing - Reusable FilterTabs component replacing ItemStatusTabs - Updated TypeScript types for sprint-related entities Also include: - Fix PocketBase file URL method casing (getUrl → getURL) across codebase - Add development utility, smoke test, and helper scripts - Update package.json with new sprint migration command
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Pencil, User } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
DEFAULT_DEV_EMAIL,
|
|
getClientIdentity,
|
|
setClientIdentity,
|
|
} from "@/lib/dev-identity";
|
|
|
|
/**
|
|
* Tiny dev-only identity switcher. The project is in "no auth" mode
|
|
* (PB users collection is empty), so the claim endpoint reads
|
|
* caller identity from a cookie. This widget lets the user see and
|
|
* change the current value, which is also how to test the
|
|
* multi-user race scenarios in /sprint.
|
|
*
|
|
* Hidden in production builds via an env check — only renders when
|
|
* `NEXT_PUBLIC_DEV_IDENTITY_SWITCHER` is "true". The /sprint page
|
|
* sets it via its own dev flag, but we re-check here as a safety
|
|
* belt.
|
|
*/
|
|
export function DevIdentitySwitcher({
|
|
onChange,
|
|
}: {
|
|
onChange?: (email: string) => void;
|
|
}) {
|
|
const [email, setEmail] = useState<string>(DEFAULT_DEV_EMAIL);
|
|
const [draft, setDraft] = useState<string>("");
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Sync the displayed identity from the cookie on mount. This is a
|
|
// legitimate "external source → state" sync (the cookie is the
|
|
// external source); we can't derive this during render because
|
|
// document.cookie is a side-effectful read.
|
|
const current = getClientIdentity();
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setEmail(current);
|
|
setDraft(current);
|
|
}, []);
|
|
|
|
const handleOpenChange = (next: boolean) => {
|
|
if (next) {
|
|
// Reset draft to the current value when opening, so cancelling
|
|
// a half-typed edit doesn't leak into the field next time.
|
|
setDraft(email);
|
|
}
|
|
setOpen(next);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const next = draft.trim() || DEFAULT_DEV_EMAIL;
|
|
setClientIdentity(next);
|
|
setEmail(next);
|
|
setOpen(false);
|
|
onChange?.(next);
|
|
};
|
|
|
|
const displayName = shortName(email);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger
|
|
aria-label="Switch dev identity"
|
|
className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors"
|
|
>
|
|
<User className="h-3 w-3" />
|
|
<span className="font-mono">{displayName}</span>
|
|
<Pencil className="h-3 w-3 opacity-60" />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-80 space-y-3">
|
|
<div>
|
|
<p className="text-sm font-medium">Dev identity</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Used for claim attribution and the “claimed by me”
|
|
badge. Switch to test multi-user race scenarios.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="dev-identity-input"
|
|
className="text-xs font-medium text-muted-foreground"
|
|
>
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="dev-identity-input"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder={DEFAULT_DEV_EMAIL}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSave();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function shortName(email: string): string {
|
|
const at = email.indexOf("@");
|
|
return at > 0 ? email.slice(0, at) : email;
|
|
}
|