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": [],
|
||||
"current_feature": "account-deletion",
|
||||
"current_feature": "project-stats-page",
|
||||
"started_at": "2026-05-23T05:49:48.673340",
|
||||
"attempted_features": [
|
||||
"recent-activity-widget",
|
||||
"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.
|
||||
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
|
||||
- `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 { db } from "../db"
|
||||
import { projects } from "../db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { projects, timeEntries, users } from "../db/schema"
|
||||
import { eq, and, sql } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
|
||||
const ProjectSchema = z.object({
|
||||
@ -53,6 +53,56 @@ export default async function projectRoutes(fastify: FastifyInstance) {
|
||||
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) => {
|
||||
const body = ProjectSchema.parse(request.body)
|
||||
|
||||
@ -100,12 +150,3 @@ export default async function projectRoutes(fastify: FastifyInstance) {
|
||||
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 { useParams, Link } from "@tanstack/react-router"
|
||||
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() {
|
||||
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>
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-100 px-4 py-3 rounded-lg text-center">
|
||||
<span className="block text-xs font-semibold text-blue-600 uppercase tracking-wider">Total Hours</span>
|
||||
<span className="text-2xl font-bold text-blue-900">{totalHours.toFixed(2)}h</span>
|
||||
<div className="bg-blue-600 text-white px-6 py-3 rounded-xl shadow-sm text-center">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider opacity-80">Total Project Hours</span>
|
||||
<span className="text-3xl font-bold">{totalHours.toFixed(2)}h</span>
|
||||
</div>
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-gray-800">Time Entries</h2>
|
||||
</div>
|
||||
@ -60,8 +129,8 @@ export default function ProjectDetail() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{entries && entries.length > 0 ? (
|
||||
entries.map((entry: any) => (
|
||||
{timeEntries.length > 0 ? (
|
||||
timeEntries.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(entry.date).toLocaleDateString()}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user