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");