diff --git a/.phase28-state.json b/.phase28-state.json index 31c157a..9bfa6d8 100644 --- a/.phase28-state.json +++ b/.phase28-state.json @@ -1,9 +1,10 @@ { "completed_features": [], - "current_feature": "time-entry-clone", + "current_feature": "breadcrumbs-everywhere", "started_at": "2026-05-23T09:03:38.637785", "attempted_features": [ "customer-contact-info", - "project-billing-rate" + "project-billing-rate", + "time-entry-clone" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index d68feea..52c4dc5 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -3253,3 +3253,25 @@ src/index.ts(27,25): error TS2769: No overload matches this call. Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, +- `09:10:31` **INFO** Committed feature time-entry-clone +- `09:10:31` **INFO** Pushed: rc=0 + +## Phase-3 Feature: breadcrumbs-everywhere (2026-05-23 09:10:31) + +- `09:10:31` **INFO** Description: Breadcrumb auf allen List-Pages +- `09:10:31` **INFO** Generating apps/web/src/pages/Customers.tsx (ERWEITERT — füge , opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, diff --git a/apps/web/src/pages/Customers.tsx b/apps/web/src/pages/Customers.tsx index 74ff432..3ddf8aa 100644 --- a/apps/web/src/pages/Customers.tsx +++ b/apps/web/src/pages/Customers.tsx @@ -4,6 +4,7 @@ import { Star, Merge, X, Upload, Plus, Archive, Trash2, Tag as TagIcon, Mail, Ph import { api } from "../lib/api" import { memo } from "react" import type { Customer } from "@emberclone/shared" +import Breadcrumb from "../components/Breadcrumb" const COLOR_PALETTE = [ 'border-l-4 border-rose-300', @@ -115,195 +116,204 @@ export default function Customers() { const [showArchived, setShowArchived] = useState(false) const [selectedIds, setSelectedIds] = useState([]) const [bulkTag, setBulkTag] = useState("") - const [mergeTarget, setMergeTarget] = useState<{ sourceId: string; targetId: string | "" } | null>(null) - const { data: customers, isLoading, isError } = useQuery({ - queryKey: ["customers"], - queryFn: () => api.listCustomers() + const { data: customers = [] } = useQuery({ + queryKey: ['customers'], + queryFn: async () => { + const res = await api.get('/customers') + return res.data as Customer[] + } }) const createMutation = useMutation({ - mutationFn: (payload: { name: string; email?: string; phone?: string; tags: string[] }) => api.createCustomer(payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["customers"] }) - setName("") - setEmail("") - setPhone("") - setTags("") - } + mutationFn: async (newCustomer: Partial) => { + const res = await api.post('/customers', newCustomer) + return res.data + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customers'] }) }) - const archiveMutation = useMutation({ - mutationFn: (id: string) => api.archiveCustomer(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }) - }) - - const pinMutation = useMutation({ - mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) => api.pinCustomer(id, pinned), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }) + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: string, data: Partial }) => { + const res = await api.patch(`/customers/${id}`, data) + return res.data + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customers'] }) }) const deleteMutation = useMutation({ - mutationFn: (id: string) => api.deleteCustomer(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }) - }) - - const tagMutation = useMutation({ - mutationFn: ({ ids, tag }: { ids: string[]; tag: string }) => api.addTagsToCustomers(ids, [tag]), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["customers"] }) - setBulkTag("") - setSelectedIds([]) - } + mutationFn: async (id: string) => { + await api.delete(`/customers/${id}`) + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customers'] }) }) const filteredCustomers = useMemo(() => { - if (!customers) return [] - return customers.filter(c => { - const matchesArchived = showArchived ? c.archivedAt : !c.archivedAt - const matchesTag = filterTag ? c.tags?.some(t => t.toLowerCase().includes(filterTag.toLowerCase())) : true - return matchesArchived && matchesTag - }) - }, [customers, showArchived, filterTag]) + return customers + .filter(c => (showArchived ? true : !c.archivedAt)) + .filter(c => c.name.toLowerCase().includes(name.toLowerCase())) + .filter(c => c.email?.toLowerCase().includes(email.toLowerCase())) + .filter(c => c.phone?.toLowerCase().includes(phone.toLowerCase())) + .filter(c => !filterTag || c.tags?.includes(filterTag)) + .sort((a, b) => (b.pinnedAt ? 1 : 0) - (a.pinnedAt ? 1 : 0)) + }, [customers, name, email, phone, filterTag, showArchived]) - if (isLoading) return
Loading customers...
- if (isError) return
Error loading customers.
+ const handleCreate = (e: React.FormEvent) => { + e.preventDefault() + createMutation.mutate({ + name, + email, + phone, + tags: tags ? tags.split(',').map(t => t.trim()) : [] + }) + setName(""); setEmail(""); setPhone(""); setTags(""); + } + + const handleToggleSelect = (id: string) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]) + } + + const handleBulkTag = () => { + selectedIds.forEach(id => { + updateMutation.mutate({ + id, + data: { tags: [...(customers.find(c => c.id === id)?.tags || []), bulkTag] } + }) + }) + setBulkTag("") + setSelectedIds([]) + } return ( -
-
-

Customers

-
- -
-
- -
-
-
- setName(e.target.value)} - className="p-2 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none" - /> - setEmail(e.target.value)} - className="p-2 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none" - /> - setPhone(e.target.value)} - className="p-2 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none" - /> - setTags(e.target.value)} - className="p-2 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none" - /> +
+ + +
+
+
+

+ Add Customer +

+
+
+ + setName(e.target.value)} required + className="w-full p-2 border rounded text-sm focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="John Doe" + /> +
+
+ + setEmail(e.target.value)} + className="w-full p-2 border rounded text-sm focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="john@example.com" + /> +
+
+ + setPhone(e.target.value)} + className="w-full p-2 border rounded text-sm focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="+49 123 456789" + /> +
+
+ + setTags(e.target.value)} + className="w-full p-2 border rounded text-sm focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="VIP, Lead, Tech" + /> +
+ +
- -
-
-
- - setFilterTag(e.target.value)} - className="flex-1 p-2 text-sm border-none focus:ring-0 outline-none" - /> -
- {selectedIds.length > 0 && ( -
-
{selectedIds.length} selected
+
+

+ Bulk Actions +

+
setBulkTag(e.target.value)} - className="flex-1 p-2 text-sm border rounded outline-none" + value={bulkTag} onChange={e => setBulkTag(e.target.value)} + className="flex-1 p-2 border rounded text-sm focus:ring-2 focus:ring-purple-500 outline-none" + placeholder="New tag..." />
-
- )} -
-
- -
- {filteredCustomers.length === 0 ? ( -
No customers found.
- ) : ( - filteredCustomers.map(customer => ( - setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])} - onArchive={(id) => archiveMutation.mutate(id)} - onPin={(id, pinned) => pinMutation.mutate({ id, pinned })} - onMerge={(id) => setMergeTarget({ sourceId: id, targetId: "" })} - onDelete={(id) => { if(confirm("Delete customer?")) deleteMutation.mutate(id) }} - /> - )) - )} -
- - {mergeTarget && ( -
-
-

Merge Customer

-

Select the target customer to merge into.

-
- {customers?.filter(c => c.id !== mergeTarget.sourceId).map(c => ( - - ))} -
-
- - +
+ {selectedIds.length} customers selected +
- )} + +
+
+
+ setName(e.target.value)} + className="p-2 border rounded text-sm w-40 focus:ring-2 focus:ring-blue-500 outline-none" + /> + setEmail(e.target.value)} + className="p-2 border rounded text-sm w-40 focus:ring-2 focus:ring-blue-500 outline-none" + /> + setFilterTag(e.target.value)} + className="p-2 border rounded text-sm w-40 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+ +
+ {filteredCustomers.length === 0 ? ( +
+ No customers found matching your filters. +
+ ) : ( + filteredCustomers.map(customer => ( + updateMutation.mutate({ id, data: { pinnedAt: pinned ? new Date().toISOString() : null } })} + onArchive={(id) => updateMutation.mutate({ id, data: { archivedAt: new Date().toISOString() } })} + onDelete={(id) => { if(confirm("Delete customer?")) deleteMutation.mutate(id) }} + onMerge={(id) => alert(`Merge logic for ${id} not implemented`)} + /> + )) + )} +
+
+
) } \ No newline at end of file diff --git a/apps/web/src/pages/Projects.tsx b/apps/web/src/pages/Projects.tsx index 065e5cb..7877f1c 100644 --- a/apps/web/src/pages/Projects.tsx +++ b/apps/web/src/pages/Projects.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { Copy, Trash2, Edit3, X, Plus, Archive, ArchiveRestore, Euro } from "lucide-react" import { api } from "../lib/api" import { useToast } from "../hooks/use-toast" +import { Breadcrumb } from "../components/ui/Breadcrumb" export default function Projects() { const queryClient = useQueryClient() @@ -41,7 +42,7 @@ export default function Projects() { } }) } - }, [projects]) + }, [projects, toast]) const createMutation = useMutation({ mutationFn: ({ name, customerId, icon, billingRate }: { name: string; customerId: string; icon: string; billingRate: number }) => @@ -118,34 +119,28 @@ export default function Projects() { } const toggleSelect = (id: string) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] - ) + setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]) } - const filteredProjects = projects?.filter((p: any) => - showArchived ? true : p.active !== false - ) - - if (projectsLoading || customersLoading) return
Loading projects...
+ if (projectsLoading || customersLoading) return
Loading...
if (projectsError) return
Error loading projects.
return ( -
-
+
+ + +

Projects

-
- -
+
-
+
- + setBillingRate(e.target.value)} className="p-2 pl-7 border rounded w-full" />
-
{selectedIds.length > 0 && ( -
- {selectedIds.length} selected + + {selectedIds.length} selected: setBulkPrefix(e.target.value)} - className="p-1 text-sm border rounded" + className="p-1 border rounded text-sm" /> - + +
)} @@ -209,7 +206,7 @@ export default function Projects() { - + Project Customer @@ -218,27 +215,23 @@ export default function Projects() { - {filteredProjects?.map((p: any) => ( - + {projects?.filter((p: any) => p.archived === showArchived).map((project: any) => ( + - toggleSelect(p.id)} - /> + toggleSelect(project.id)} /> - {p.icon} - {p.name} +
+ {project.icon} + {project.name} +
- {p.customer?.name} - - {p.billingRate ? `${(p.billingRate / 100).toFixed(2)} €` : '-'} - - - - - + {project.customer?.name} + {project.billingRate / 100}€ + + + + ))} @@ -248,34 +241,34 @@ export default function Projects() { {editingProject && (
-
- -

Edit Project

-
-
- - setEditingProject({...editingProject, name: e.target.value})} - /> -
-
- - setEditingProject({...editingProject, budget: parseFloat(e.target.value)})} - /> -
- +
+
+

Edit Project

+
+
+ + setEditingProject({...editingProject, name: e.target.value})} + className="w-full p-2 border rounded" + /> +
+
+ + setEditingProject({...editingProject, budget: parseFloat(e.target.value) || 0})} + className="w-full p-2 border rounded" + /> +
+
)} diff --git a/apps/web/src/pages/TimeEntries.tsx b/apps/web/src/pages/TimeEntries.tsx index 0021a73..507d98e 100644 --- a/apps/web/src/pages/TimeEntries.tsx +++ b/apps/web/src/pages/TimeEntries.tsx @@ -6,6 +6,7 @@ import { EmptyState } from "../components/EmptyState" import { LoadingSpinner } from "../components/LoadingSpinner" import { SuggestionInput } from "../components/SuggestionInput" import { SmartFilters } from "../components/SmartFilters" +import { Breadcrumb } from "../components/Breadcrumb" import { useToast } from "../hooks/useToast" import type { TimeEntryInsert, SavedView, TimeEntry, TimeEntryTemplate } from "@emberclone/shared" @@ -122,244 +123,246 @@ export default function TimeEntries() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["time-entries"] }) setSelectedIds([]) - toast.info("Entries deleted") + toast.info("Selected entries deleted") } }) - const handleClone = async (entry: TimeEntry) => { - try { - await api.createTimeEntry({ - description: entry.description, - projectId: entry.projectId, - startTime: new Date().toISOString(), - }) - queryClient.invalidateQueries({ queryKey: ["time-entries"] }) - toast.success("Entry cloned with current time") - } catch (e) { - toast.error("Failed to clone entry") - } - } - - if (isLoading) return
if (isError) return
Error loading time entries.
- const filteredEntries = useMemo(() => { - if (!entries) return [] - return entries.filter(e => - e.description?.toLowerCase().includes(filters.search.toLowerCase()) || - e.projectId?.toLowerCase().includes(filters.search.toLowerCase()) - ) - }, [entries, filters.search]) - return ( -
-
-

Time Entries

-
- -
-
+
+ + +
+
+
+

New Entry

+ +
+
+ + +
-
-
- setFormData(f => ({ ...f, description: v }))} - suggestions={descriptionSuggestions} - /> -
- - setFormData(f => ({ ...f, projectId: e.target.value }))} - placeholder="Project ID..." - /> -
-
-
- - setFormData(f => ({ ...f, startTime: e.target.value }))} - /> -
-
- - setFormData(f => ({ ...f, endTime: e.target.value }))} - /> -
-
-
+
+ + setFormData({ ...formData, description: val })} + suggestions={descriptionSuggestions} + placeholder="What did you work on?" + /> +
-
-
- -