From d0d55084c83940c2412d7a6aee46ce10dcc1efbc Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Thu, 8 Oct 2026 14:55:14 +0000 Subject: [PATCH] feat(entries): duplicar registro (copia identica na hora) --- src/app/(app)/actions.ts | 4 ++++ src/app/(app)/entry-list.tsx | 14 +++++++++++--- src/server/time-entries.test.ts | 32 ++++++++++++++++++++++++++++++++ src/server/time-entries.ts | 15 +++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/app/(app)/actions.ts b/src/app/(app)/actions.ts index 58c48a7..5c23165 100644 --- a/src/app/(app)/actions.ts +++ b/src/app/(app)/actions.ts @@ -49,6 +49,10 @@ export async function resumeEntryAction(id: string): Promise { return mutate((t) => t.timeEntries.startFrom(id)); } +export async function duplicateEntryAction(id: string): Promise { + return mutate((t) => t.timeEntries.duplicate(id)); +} + export async function stopTimerAction(): Promise { return mutate((t) => t.timeEntries.stopTimer()); } diff --git a/src/app/(app)/entry-list.tsx b/src/app/(app)/entry-list.tsx index bc83abd..2322ae1 100644 --- a/src/app/(app)/entry-list.tsx +++ b/src/app/(app)/entry-list.tsx @@ -1,13 +1,13 @@ "use client"; -import { ChevronDown, ChevronRight, Play, Trash2 } from "lucide-react"; +import { ChevronDown, ChevronRight, Copy, Play, Trash2 } from "lucide-react"; import Link from "next/link"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatDayLabel, formatWeekLabel, groupEntries } from "@/lib/entry-groups"; import { formatHms } from "@/lib/time"; -import { deleteEntryAction, restoreEntryAction, resumeEntryAction } from "./actions"; +import { deleteEntryAction, duplicateEntryAction, restoreEntryAction, resumeEntryAction } from "./actions"; import { EntryDialog } from "./entry-dialog"; import type { CatalogView, EntryView } from "./types"; @@ -46,6 +46,7 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref } run(() => deleteEntryAction(entry.id), "Não foi possível remover.", () => setRemoved({ id: entry.id, label: entry.description || "Registro sem descrição" })); const undo = (id: string) => run(() => restoreEntryAction(id), "Não foi possível desfazer.", () => setRemoved(null)); const resume = (entry: EntryView) => run(() => resumeEntryAction(entry.id), "Não foi possível iniciar."); + const duplicate = (entry: EntryView) => run(() => duplicateEntryAction(entry.id), "Não foi possível duplicar."); const toggle = (key: string) => setExpanded((current) => { const next = new Set(current); @@ -94,6 +95,13 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref }

+ {entry.endedAt !== null ? ( + + ) : ( + + )} @@ -168,7 +176,7 @@ export function EntryList({ entries, catalog, today, currentWeek, loadMoreHref }

{formatHms(group.totalSeconds)}

{running &&

+ em andamento

} - + {open && group.entries.map((entry) => entryRow(entry, true))} diff --git a/src/server/time-entries.test.ts b/src/server/time-entries.test.ts index c9a197c..ff4be4d 100644 --- a/src/server/time-entries.test.ts +++ b/src/server/time-entries.test.ts @@ -138,6 +138,38 @@ describe("time entries", () => { await tA.projects.update(projA.id, { isArchived: false }); }); + it("duplicates a finished entry with the same times, project, task, tags and billing flag", async () => { + const source = await tA.timeEntries.createManual({ + description: "revisão", + projectId: projA.id, + taskId: taskA.id, + tagIds: [tagA.id], + isBillable: false, + startedAt: "2026-02-27T09:00:00Z", + endedAt: "2026-02-27T10:30:00Z", + }); + const copy = await tA.timeEntries.duplicate(source.id); + expect(copy).toMatchObject({ + id: expect.not.stringMatching(source.id), + description: "revisão", + projectId: projA.id, + taskId: taskA.id, + tagIds: [tagA.id], + isBillable: false, + startedAt: source.startedAt, + endedAt: source.endedAt, + }); + const day = await tA.timeEntries.list({ from: iso("2026-02-27T00:00:00Z"), to: iso("2026-02-28T00:00:00Z") }); + expect(day.filter((e) => e.description === "revisão")).toHaveLength(2); + }); + + it("cannot duplicate a running timer or someone else's entry", async () => { + const running = await tA.timeEntries.startTimer({ description: "em andamento" }); + await expect(tA.timeEntries.duplicate(running.id)).rejects.toBeInstanceOf(ValidationError); + const theirs = await tMember.timeEntries.createManual({ startedAt: "2026-02-27T09:00:00Z", endedAt: "2026-02-27T10:00:00Z" }); + await expect(tA.timeEntries.duplicate(theirs.id)).rejects.toBeInstanceOf(NotFoundError); + }); + it("different users may run timers at the same time", async () => { await tA.timeEntries.startTimer({}); await expect(tMember.timeEntries.startTimer({})).resolves.toBeDefined(); diff --git a/src/server/time-entries.ts b/src/server/time-entries.ts index 97e8dbc..2abbe70 100644 --- a/src/server/time-entries.ts +++ b/src/server/time-entries.ts @@ -251,6 +251,21 @@ export function createTimeEntries(db: TenantDb, ctx: TenantContext, deps: Deps) }); }, + /** Copies a finished entry as is (same times, description, project, task, tags and billing flag) as a new entry. */ + async duplicate(id: string): Promise { + const [source] = await withTags([await getOwn(id)]); + if (!source.endedAt) throw new ValidationError({ form: "Pare o timer antes de duplicar o registro." }); + return repo.createManual({ + description: source.description, + projectId: source.projectId, + taskId: source.taskId, + tagIds: source.tagIds, + isBillable: source.isBillable, + startedAt: source.startedAt, + endedAt: source.endedAt, + }); + }, + async stopTimer(): Promise { const { here } = await runningTimer(); if (!here) throw new NotFoundError("Running timer");