feat(dashboard): Log work button and form
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
123be346a3
commit
2f55ca0114
2 changed files with 203 additions and 0 deletions
|
|
@ -12,6 +12,8 @@ import { Methodology } from "@/components/dashboard/methodology";
|
|||
import { RecentActivity } from "@/components/dashboard/recent-activity";
|
||||
import { RecomputeButton } from "@/components/dashboard/recompute-button";
|
||||
import { RefreshCommitsButton } from "@/components/dashboard/refresh-commits-button";
|
||||
import { aiConfigured } from "@/lib/ai/settings";
|
||||
import { LogWorkPanel } from "@/components/dashboard/log-work-panel";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getServerPb } from "@/lib/pb/server";
|
||||
import { loadBoard } from "@/lib/data/board";
|
||||
|
|
@ -88,6 +90,16 @@ export default async function DashboardPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{session.role === "owner" && (
|
||||
<LogWorkPanel
|
||||
projectId={board.project.id}
|
||||
clientSlug={clientSlug}
|
||||
projectSlug={projectSlug}
|
||||
cards={board.cards}
|
||||
assistantAvailable={await aiConfigured()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!metrics || metrics.totalCommits === 0 ? (
|
||||
<div className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-10 text-center">
|
||||
<h2 className="text-base font-medium text-text-primary">
|
||||
|
|
|
|||
191
src/components/dashboard/log-work-panel.tsx
Normal file
191
src/components/dashboard/log-work-panel.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
|
||||
import { logWorkAction, polishNoteAction } from "@/app/actions/log-work";
|
||||
import type { CardRecord } from "@/lib/pb/types";
|
||||
|
||||
/**
|
||||
* The owner's way to put off-repo work onto the dashboard.
|
||||
*
|
||||
* Follows the same inline-expand pattern as NewProjectForm and OwnerAdd
|
||||
* elsewhere in this app, rather than introducing a modal/dialog component
|
||||
* that does not otherwise exist in the codebase.
|
||||
*/
|
||||
export function LogWorkPanel({
|
||||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
cards,
|
||||
assistantAvailable,
|
||||
}: {
|
||||
projectId: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
cards: CardRecord[];
|
||||
assistantAvailable: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [hours, setHours] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [cardId, setCardId] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [polishing, setPolishing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
Log work
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const field =
|
||||
"w-full rounded-lg border border-border-subtle bg-panel px-3 py-2 text-sm text-text-primary outline-none focus:border-brand";
|
||||
|
||||
function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const parsedHours = Number(hours);
|
||||
if (!message.trim() || !Number.isFinite(parsedHours) || parsedHours <= 0) return;
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await logWorkAction({
|
||||
projectId,
|
||||
clientSlug,
|
||||
projectSlug,
|
||||
date,
|
||||
hours: parsedHours,
|
||||
message,
|
||||
cardId: cardId || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.message ?? "Could not log that work");
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setHours("");
|
||||
setMessage("");
|
||||
setCardId("");
|
||||
});
|
||||
}
|
||||
|
||||
async function polish() {
|
||||
if (!message.trim()) return;
|
||||
setPolishing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await polishNoteAction({ text: message });
|
||||
if (result.ok && result.text) setMessage(result.text);
|
||||
else setError(result.message ?? "Could not polish that note");
|
||||
} finally {
|
||||
setPolishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted">Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
className={field}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted">Hours</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
value={hours}
|
||||
onChange={(event) => setHours(event.target.value)}
|
||||
placeholder="3.5"
|
||||
className={field}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-text-muted">What was worked on</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
className={field}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{cards.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-text-muted">Link to a card (optional)</span>
|
||||
<select
|
||||
value={cardId}
|
||||
onChange={(event) => setCardId(event.target.value)}
|
||||
className={field}
|
||||
>
|
||||
<option value="">Not linked to a card</option>
|
||||
{cards.map((card) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
#{card.number} {card.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-accent">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !message.trim() || !hours}
|
||||
className="rounded-lg bg-brand px-3 py-2 text-sm font-medium text-brand-fg disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Logging…" : "Log work"}
|
||||
</button>
|
||||
|
||||
{assistantAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={polish}
|
||||
disabled={polishing || !message.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-2 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={13} strokeWidth={2} />
|
||||
{polishing ? "Polishing…" : "Polish with AI"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-2 py-2 text-sm text-text-muted hover:text-text-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue