feat(settings-page): App-Settings (workspace name, default-billable, etc.) [tsc:fail]
This commit is contained in:
parent
ea89138e71
commit
6131788183
@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [
|
"completed_features": [
|
||||||
"dark-mode-toggle",
|
"dark-mode-toggle",
|
||||||
"customer-detail-page"
|
"customer-detail-page",
|
||||||
|
"project-detail-page"
|
||||||
],
|
],
|
||||||
"current_feature": "project-detail-page",
|
"current_feature": "settings-page",
|
||||||
"started_at": "2026-05-23T05:20:11.407108"
|
"started_at": "2026-05-23T05:20:11.407108"
|
||||||
}
|
}
|
||||||
@ -571,3 +571,21 @@ undefined
|
|||||||
- `05:22:50` **INFO** wrote 3762 chars in 31.4s (attempt 1)
|
- `05:22:50` **INFO** wrote 3762 chars in 31.4s (attempt 1)
|
||||||
- `05:22:50` **INFO** Running tsc --noEmit on api…
|
- `05:22:50` **INFO** Running tsc --noEmit on api…
|
||||||
- `05:22:52` **INFO** tsc clean ✓
|
- `05:22:52` **INFO** tsc clean ✓
|
||||||
|
- `05:22:52` **INFO** Committed feature project-detail-page
|
||||||
|
- `05:22:52` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-3 Feature: settings-page (2026-05-23 05:22:52)
|
||||||
|
|
||||||
|
- `05:22:52` **INFO** Description: App-Settings (workspace name, default-billable, etc.)
|
||||||
|
- `05:22:52` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — behalte alle bestehenden Tabellen. Füge neue Tabelle `appS…)
|
||||||
|
- `05:23:07` **INFO** wrote 1815 chars in 15.4s (attempt 1)
|
||||||
|
- `05:23:07` **INFO** Generating apps/api/src/routes/settings.ts (Fastify-Plugin /api/settings. GET / (current settings, lazy-init if no…)
|
||||||
|
- `05:23:29` **INFO** wrote 2647 chars in 21.8s (attempt 1)
|
||||||
|
- `05:23:29` **INFO** Generating apps/web/src/pages/Settings.tsx (Settings-Page. Form mit workspaceName, defaultBillable (checkbox), wee…)
|
||||||
|
- `05:24:09` **INFO** wrote 4829 chars in 39.7s (attempt 1)
|
||||||
|
- `05:24:09` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `05:24:10` **WARN** tsc errors:
|
||||||
|
src/routes/settings.ts(3,10): error TS2305: Module '"../db/schema"' has no exported member 'settings'.
|
||||||
|
undefined
|
||||||
|
/home/dark/Developer/EmberClone/apps/api:
|
||||||
|
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2: tsc --noEmit -p tsconfig.json
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, timestamp, boolean, uuid } from "drizzle-orm/pg-core"
|
import { pgTable, text, timestamp, boolean, uuid, integer } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@ -32,4 +32,12 @@ export const timeEntries = pgTable("time_entries", {
|
|||||||
startTime: timestamp("start_time").notNull(),
|
startTime: timestamp("start_time").notNull(),
|
||||||
endTime: timestamp("end_time"),
|
endTime: timestamp("end_time"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appSettings = pgTable("app_settings", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceName: text("workspace_name").notNull().default("EmberClone"),
|
||||||
|
defaultBillable: boolean("default_billable").notNull().default(true),
|
||||||
|
weekStart: integer("week_start").notNull().default(1),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
||||||
})
|
})
|
||||||
95
apps/api/src/routes/settings.ts
Normal file
95
apps/api/src/routes/settings.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { db } from "../db"
|
||||||
|
import { settings } from "../db/schema"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const SettingsUpdateSchema = z.object({
|
||||||
|
siteName: z.string().min(1).optional(),
|
||||||
|
maintenanceMode: z.boolean().optional(),
|
||||||
|
contactEmail: z.string().email().optional(),
|
||||||
|
allowRegistration: z.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default async function settingsRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook("preHandler", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(401).send({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = (request: any) => {
|
||||||
|
return (request.user as { sub: string, role: string })?.role === "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
fastify.get("/", async (request, reply) => {
|
||||||
|
const [currentSettings] = await db
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentSettings) {
|
||||||
|
// Lazy-init default settings if none exist
|
||||||
|
const [defaults] = await db
|
||||||
|
.insert(settings)
|
||||||
|
.values({
|
||||||
|
siteName: "EmberClone",
|
||||||
|
maintenanceMode: false,
|
||||||
|
allowRegistration: true,
|
||||||
|
contactEmail: "admin@example.com"
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSettings
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.patch("/", async (request, reply) => {
|
||||||
|
if (!isAdmin(request)) {
|
||||||
|
return reply.code(403).send({ message: "Forbidden: Admin role required" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = SettingsUpdateSchema.parse(request.body)
|
||||||
|
|
||||||
|
// We assume there is only one settings row (singleton pattern)
|
||||||
|
const [updated] = await db
|
||||||
|
.update(settings)
|
||||||
|
.set(body)
|
||||||
|
.where(eq(settings.id, "global")) // Assuming a fixed ID for global settings
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// If no row with ID 'global' exists, we create it or update the first available
|
||||||
|
const [existing] = await db.select().from(settings).limit(1)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(settings)
|
||||||
|
.values({
|
||||||
|
id: "global",
|
||||||
|
...body,
|
||||||
|
siteName: body.siteName ?? "EmberClone",
|
||||||
|
maintenanceMode: body.maintenanceMode ?? false,
|
||||||
|
allowRegistration: body.allowRegistration ?? true,
|
||||||
|
contactEmail: body.contactEmail ?? "admin@example.com"
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const [finalUpdate] = await db
|
||||||
|
.update(settings)
|
||||||
|
.set(body)
|
||||||
|
.where(eq(settings.id, existing.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return finalUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
131
apps/web/src/pages/Settings.tsx
Normal file
131
apps/web/src/pages/Settings.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useToast } from '../components/Toast';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [workspaceName, setWorkspaceName] = useState('');
|
||||||
|
const [defaultBillable, setDefaultBillable] = useState(false);
|
||||||
|
const [weekStart, setWeekStart] = useState('Monday');
|
||||||
|
|
||||||
|
const { data: settings, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => api.getSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: () => api.getMe(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (data: { workspaceName: string; defaultBillable: boolean; weekStart: string }) => {
|
||||||
|
return api.updateSettings(data);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Einstellungen erfolgreich aktualisiert');
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Fehler beim Aktualisieren der Einstellungen');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setWorkspaceName(settings.workspaceName || '');
|
||||||
|
setDefaultBillable(settings.defaultBillable ?? false);
|
||||||
|
setWeekStart(settings.weekStart || 'Monday');
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateMutation.mutate({
|
||||||
|
workspaceName,
|
||||||
|
defaultBillable,
|
||||||
|
weekStart,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-slate-900"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">Zugriff verweigert</h1>
|
||||||
|
<p className="text-slate-500 mt-2">Diese Seite ist nur für Administratoren zugänglich.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-10 px-4">
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
|
||||||
|
<h1 className="text-xl font-semibold text-slate-800">Workspace Einstellungen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Workspace Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">Workspace Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workspaceName}
|
||||||
|
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
|
placeholder="z.B. Meine Agentur GmbH"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Billable */}
|
||||||
|
<div className="flex items-center space-x-3 py-2">
|
||||||
|
<input
|
||||||
|
id="defaultBillable"
|
||||||
|
type="checkbox"
|
||||||
|
checked={defaultBillable}
|
||||||
|
onChange={(e) => setDefaultBillable(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label htmlFor="defaultBillable" className="text-sm font-medium text-slate-700 cursor-pointer">
|
||||||
|
Neue Einträge standardmäßig als abrechenbar markieren
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Week Start */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">Wochenstart</label>
|
||||||
|
<select
|
||||||
|
value={weekStart}
|
||||||
|
onChange={(e) => setWeekStart(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all bg-white"
|
||||||
|
>
|
||||||
|
<option value="Monday">Montag</option>
|
||||||
|
<option value="Sunday">Sonntag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-md hover:bg-slate-800 disabled:opacity-50 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Speichert...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user