feat(project-stats-page): Project-Stats: Stunden total, monthly chart, top contributor [tsc:fail]
This commit is contained in:
parent
454b85b4e2
commit
23c95940e6
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [],
|
"completed_features": [],
|
||||||
"current_feature": "account-deletion",
|
"current_feature": "project-stats-page",
|
||||||
"started_at": "2026-05-23T05:49:48.673340",
|
"started_at": "2026-05-23T05:49:48.673340",
|
||||||
"attempted_features": [
|
"attempted_features": [
|
||||||
"recent-activity-widget",
|
"recent-activity-widget",
|
||||||
"time-entry-bulk-actions",
|
"time-entry-bulk-actions",
|
||||||
"customer-csv-import"
|
"customer-csv-import",
|
||||||
|
"account-deletion"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -966,3 +966,22 @@ src/index.ts(27,25): error TS2769: No overload matches this call.
|
|||||||
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
||||||
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||||
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||||
|
- `05:57:27` **INFO** Committed feature account-deletion
|
||||||
|
- `05:57:27` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-3 Feature: project-stats-page (2026-05-23 05:57:27)
|
||||||
|
|
||||||
|
- `05:57:27` **INFO** Description: Project-Stats: Stunden total, monthly chart, top contributors
|
||||||
|
- `05:57:27` **INFO** Generating apps/api/src/routes/projects.ts (ERWEITERT — behalte alles. Füge GET /:id/stats: {totalHours, entryCoun…)
|
||||||
|
- `05:58:03` **INFO** wrote 3936 chars in 36.3s (attempt 1)
|
||||||
|
- `05:58:03` **INFO** Generating apps/web/src/pages/ProjectDetail.tsx (ERWEITERT — behalte bestehendes (header, time-entries). Füge Stats-Car…)
|
||||||
|
- `05:59:03` **INFO** wrote 7164 chars in 60.1s (attempt 1)
|
||||||
|
- `05:59:03` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `05:59:05` **WARN** tsc errors:
|
||||||
|
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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||||
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'.
|
||||||
|
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
||||||
|
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||||
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { db } from "../db"
|
import { db } from "../db"
|
||||||
import { projects } from "../db/schema"
|
import { projects, timeEntries, users } from "../db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq, and, sql } from "drizzle-orm"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
const ProjectSchema = z.object({
|
const ProjectSchema = z.object({
|
||||||
@ -53,6 +53,56 @@ export default async function projectRoutes(fastify: FastifyInstance) {
|
|||||||
return project
|
return project
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fastify.get("/:id/stats", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
|
||||||
|
const projectExists = await db
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!projectExists.length) {
|
||||||
|
return reply.code(404).send({ message: "Project not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStats = await db
|
||||||
|
.select({
|
||||||
|
totalHours: sql<number>`sum(hours)`,
|
||||||
|
entryCount: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.where(eq(timeEntries.projectId, id))
|
||||||
|
|
||||||
|
const byUser = await db
|
||||||
|
.select({
|
||||||
|
userId: users.id,
|
||||||
|
name: users.name,
|
||||||
|
hours: sql<number>`sum(hours)`,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||||
|
.where(eq(timeEntries.projectId, id))
|
||||||
|
.groupBy(users.id, users.name)
|
||||||
|
|
||||||
|
const byMonth = await db
|
||||||
|
.select({
|
||||||
|
month: sql`date_trunc('month', date)`,
|
||||||
|
hours: sql<number>`sum(hours)`,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.where(eq(timeEntries.projectId, id))
|
||||||
|
.groupBy(sql`date_trunc('month', date)`)
|
||||||
|
.orderBy(sql`date_trunc('month', date)`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHours: totalStats[0]?.totalHours || 0,
|
||||||
|
entryCount: totalStats[0]?.entryCount || 0,
|
||||||
|
byUser,
|
||||||
|
byMonth,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
fastify.post("/", async (request, reply) => {
|
fastify.post("/", async (request, reply) => {
|
||||||
const body = ProjectSchema.parse(request.body)
|
const body = ProjectSchema.parse(request.body)
|
||||||
|
|
||||||
@ -100,12 +150,3 @@ export default async function projectRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for the filter array since 'and' is needed
|
|
||||||
function and(...conditions: any[]) {
|
|
||||||
return conditions.length > 0 ? conditions : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overriding the local 'and' to use drizzle's actual 'and'
|
|
||||||
import { and as drizzleAnd } from "drizzle-orm"
|
|
||||||
const andHelper = drizzleAnd;
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { useParams, Link } from "@tanstack/react-router"
|
import { useParams, Link } from "@tanstack/react-router"
|
||||||
import { api } from "../lib/api"
|
import { api } from "../lib/api"
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
|
import type { Project, TimeEntry } from "@emberclone/shared"
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
const { projectId } = useParams({ strict: false })
|
const { projectId } = useParams({ strict: false })
|
||||||
@ -25,7 +27,29 @@ export default function ProjectDetail() {
|
|||||||
return <div className="p-6 text-red-500">Project not found or error loading data.</div>
|
return <div className="p-6 text-red-500">Project not found or error loading data.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalHours = entries?.reduce((sum: number, entry: any) => sum + (entry.hours || 0), 0) || 0
|
const timeEntries = (entries as TimeEntry[]) || []
|
||||||
|
const totalHours = timeEntries.reduce((sum, entry) => sum + (entry.hours || 0), 0)
|
||||||
|
|
||||||
|
// Process Monthly Data for Chart
|
||||||
|
const monthlyData = timeEntries.reduce((acc: Record<string, number>, entry) => {
|
||||||
|
const date = new Date(entry.date)
|
||||||
|
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' })
|
||||||
|
acc[month] = (acc[month] || 0) + entry.hours
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const chartData = Object.entries(monthlyData).map(([name, hours]) => ({ name, hours }))
|
||||||
|
|
||||||
|
// Process Top Contributors
|
||||||
|
const contributorMap = timeEntries.reduce((acc: Record<string, number>, entry) => {
|
||||||
|
const user = entry.user?.name || "Unknown"
|
||||||
|
acc[user] = (acc[user] || 0) + entry.hours
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const topContributors = Object.entries(contributorMap)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||||
@ -39,13 +63,58 @@ export default function ProjectDetail() {
|
|||||||
Customer: <span className="font-medium text-gray-700">{project.customer?.name || "Unknown"}</span>
|
Customer: <span className="font-medium text-gray-700">{project.customer?.name || "Unknown"}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-50 border border-blue-100 px-4 py-3 rounded-lg text-center">
|
<div className="bg-blue-600 text-white px-6 py-3 rounded-xl shadow-sm text-center">
|
||||||
<span className="block text-xs font-semibold text-blue-600 uppercase tracking-wider">Total Hours</span>
|
<span className="block text-xs font-semibold uppercase tracking-wider opacity-80">Total Project Hours</span>
|
||||||
<span className="text-2xl font-bold text-blue-900">{totalHours.toFixed(2)}h</span>
|
<span className="text-3xl font-bold">{totalHours.toFixed(2)}h</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<section className="lg:col-span-2 bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">Monthly Distribution</h3>
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#9ca3af' }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#9ca3af' }} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: '#f9fafb' }}
|
||||||
|
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="hours" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={40} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-gray-400 italic">No data available for chart</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">Top Contributors</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{topContributors.length > 0 ? (
|
||||||
|
topContributors.map(([name, hours]) => (
|
||||||
|
<div key={name} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
|
||||||
|
{name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">{name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">{hours.toFixed(1)}h</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400 italic text-sm">No contributors yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold text-gray-800">Time Entries</h2>
|
<h2 className="text-lg font-semibold text-gray-800">Time Entries</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -60,8 +129,8 @@ export default function ProjectDetail() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{entries && entries.length > 0 ? (
|
{timeEntries.length > 0 ? (
|
||||||
entries.map((entry: any) => (
|
timeEntries.map((entry) => (
|
||||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
{new Date(entry.date).toLocaleDateString()}
|
{new Date(entry.date).toLocaleDateString()}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user