Initiale Arbeitszeiterfassung (Next.js 16 + SQLite)
Web-App zur Erfassung der Arbeitszeit, Layout/Architektur an Projekt logbuch angelehnt, mit grüner Grundfarbe und SQLite3 statt MySQL. - Eingabemaske: Datum (mit Wochentag), Ort (Kunde FFM/Homeoffice/Andrena), Beginn/Ende, Reisezeit, Vertriebsunterstützung (default 2h), Kommentar (max 500) - Berechnung: Arbeitszeit = Ende-Beginn; Mehrarbeit = Arbeitszeit - 8h - Pause - Pause als globale Einstellung (Einstellungen-Tab) - Tabs Eingabe/Liste/Einstellungen, letzte 10 Einträge unter der Maske - Single-User-Login (Benutzer Birgit + Passwort fest in .env), JWT-Cookie via jose - better-sqlite3, Docker-Setup für docker.citysensor.de Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# SQLite-Datenbank (Laufzeitdaten)
|
||||
/data/
|
||||
@@ -0,0 +1,47 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Hinweise für Claude Code (claude.ai/code) zur Arbeit in diesem Repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Entwicklungsserver
|
||||
npm run build # Produktions-Build (nach jeder Änderung zur Prüfung ausführen)
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
Kein Test-Setup. Deploy via `./deploy.sh [tag]` — baut ein Multiplatform-Docker-Image
|
||||
(amd64 + arm64) und pusht es nach `docker.citysensor.de`.
|
||||
|
||||
## Architektur
|
||||
|
||||
Next.js 16 App-Router-Anwendung. Seiten sind Server-Komponenten; interaktive Teile sind
|
||||
Client-Komponenten in `app/MainClient.tsx` und `components/`. Layout und Design sind dem
|
||||
Schwesterprojekt `~/Projekte/logbuch` nachempfunden, jedoch mit **grüner** Grundfarbe.
|
||||
|
||||
**Auth**: Single-User. Benutzername und Passwort sind fest in `.env` hinterlegt
|
||||
(`APP_USERNAME` default `Birgit`, `APP_PASSWORD`). Login über `app/login/actions.ts` →
|
||||
`lib/auth.ts` (Plaintext-Vergleich, kein bcrypt). Sessions sind JWT-Cookies via `jose`
|
||||
(`lib/session.ts`, 8 h Gültigkeit, Cookie `arbeitszeit_session`). Middleware liegt in
|
||||
`proxy.ts` (Next.js-16-Konvention) und exportiert `middleware`.
|
||||
|
||||
**Datenbank**: SQLite3 via `better-sqlite3`. `lib/db.ts` ist ein Singleton, legt Schema und
|
||||
die `settings`-Tabelle beim ersten Zugriff an. Datei unter `DB_PATH`
|
||||
(default `./data/arbeitszeit.db`, in Produktion `/app/data/arbeitszeit.db` auf einem Volume).
|
||||
`better-sqlite3` ist nativ → `serverExternalPackages` in `next.config.ts` und im Dockerfile
|
||||
`python3 make g++` in der `deps`-Stage.
|
||||
|
||||
**Datenzugriff**: `lib/repo.ts` (CRUD + Pause-Setting), `lib/calc.ts` (Zeit-/Wochentag-Helfer),
|
||||
`lib/validate.ts` (Body-Validierung). API-Routen unter `app/api/` prüfen alle `getSession()`
|
||||
und liefern 401 ohne Session.
|
||||
|
||||
## Datenmodell & Berechnungen
|
||||
|
||||
Tabelle `arbeitszeit`: `datum`, `ort` (Kunde FFM | Homeoffice | Andrena), `beginn`, `ende`
|
||||
(je HH:MM), `reisezeit`, `vertriebsunterstuetzung` (default 2), `kommentar` (max. 500).
|
||||
|
||||
- `Arbeitszeit = Ende − Beginn` (dezimale Stunden)
|
||||
- `Mehrarbeit = Arbeitszeit − 8 − Pause`
|
||||
- Die Spalte „Arbeitszeit Kunde" ist fix 8 h (`ARBEITSZEIT_KUNDE_FIX`).
|
||||
- Die **Pause** ist eine globale Einstellung in der `settings`-Tabelle (Tab „Einstellungen").
|
||||
Mehrarbeit wird zur Laufzeit berechnet, nicht gespeichert.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
# Build-Tools für die native Kompilierung von better-sqlite3 (node-gyp)
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DATE
|
||||
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Verzeichnis für die SQLite-Datenbank (per Volume gemountet)
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV DB_PATH=/app/data/arbeitszeit.db
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ArbeitszeitEintrag } from '@/types/arbeitszeit';
|
||||
import ArbeitszeitForm from '@/components/ArbeitszeitForm';
|
||||
import ArbeitszeitList from '@/components/ArbeitszeitList';
|
||||
import Einstellungen from '@/components/Einstellungen';
|
||||
import packageJson from '@/package.json';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
}
|
||||
|
||||
type Tab = 'eingabe' | 'liste' | 'einstellungen';
|
||||
|
||||
export default function MainClient({ username }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('eingabe');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [editEntry, setEditEntry] = useState<ArbeitszeitEintrag | null>(null);
|
||||
const [pause, setPause] = useState(0.5);
|
||||
|
||||
const version = packageJson.version;
|
||||
const buildDate =
|
||||
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => { if (data && typeof data.pause === 'number') setPause(data.pause); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleSaved() {
|
||||
setRefreshKey((k) => k + 1);
|
||||
setEditEntry(null);
|
||||
}
|
||||
|
||||
function handleEdit(entry: ArbeitszeitEintrag) {
|
||||
setEditEntry(entry);
|
||||
setActiveTab('eingabe');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'eingabe', label: 'Eingabe' },
|
||||
{ id: 'liste', label: 'Liste' },
|
||||
{ id: 'einstellungen', label: 'Einstellungen' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0">
|
||||
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF7EE] print:max-w-none print:border-0 print:p-0 print:bg-white">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden">
|
||||
<h1 className="text-xl sm:text-2xl font-bold leading-tight text-gray-900">
|
||||
Arbeitszeiterfassung
|
||||
<span className="hidden sm:inline text-base font-normal text-gray-600"> — {username}</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded-lg text-white shrink-0"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
if (tab.id === 'eingabe') setEditEntry(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab.id
|
||||
? 'border-[#86C28B] text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Eingabe-Tab */}
|
||||
{activeTab === 'eingabe' && (
|
||||
<>
|
||||
{editEntry && (
|
||||
<div className="mb-3 text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2">
|
||||
Eintrag bearbeiten (ID {editEntry.id})
|
||||
</div>
|
||||
)}
|
||||
<ArbeitszeitForm
|
||||
key={editEntry?.id ?? 'new'}
|
||||
editEntry={editEntry}
|
||||
pause={pause}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setEditEntry(null)}
|
||||
/>
|
||||
|
||||
{/* Letzte 10 Einträge unter der Eingabemaske */}
|
||||
<div className="mt-5 border-t-2 border-gray-300 pt-4">
|
||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2>
|
||||
<button
|
||||
onClick={() => setActiveTab('liste')}
|
||||
className="text-xs text-green-700 hover:underline"
|
||||
>
|
||||
Alle anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
<ArbeitszeitList
|
||||
pause={pause}
|
||||
refreshKey={refreshKey}
|
||||
onEdit={handleEdit}
|
||||
limit={10}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Liste-Tab */}
|
||||
{activeTab === 'liste' && (
|
||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
||||
<ArbeitszeitList pause={pause} refreshKey={refreshKey} onEdit={handleEdit} limit={100} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Einstellungen-Tab */}
|
||||
{activeTab === 'einstellungen' && (
|
||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-3">Einstellungen</h2>
|
||||
<Einstellungen pause={pause} onPauseChange={setPause} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
|
||||
<a href="mailto:rxf@gmx.de" className="text-green-700 hover:underline">rxf@gmx.de</a>
|
||||
<div className="text-right">v{version} — {buildDate}</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { updateArbeitszeit, deleteArbeitszeit } from '@/lib/repo';
|
||||
import { parseArbeitszeitInput } from '@/lib/validate';
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
const id = parseInt((await params).id, 10);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const input = parseArbeitszeitInput(await request.json());
|
||||
const entry = updateArbeitszeit(id, input);
|
||||
if (!entry) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 });
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Datenbankfehler';
|
||||
console.error('PUT /api/arbeitszeit/[id]:', error);
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
const id = parseInt((await params).id, 10);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const ok = deleteArbeitszeit(id);
|
||||
if (!ok) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('DELETE /api/arbeitszeit/[id]:', error);
|
||||
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { listArbeitszeit, createArbeitszeit } from '@/lib/repo';
|
||||
import { parseArbeitszeitInput } from '@/lib/validate';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '15') || 15, 500);
|
||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
|
||||
const month = searchParams.get('month') || undefined;
|
||||
|
||||
try {
|
||||
const result = listArbeitszeit({ limit, offset, month });
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('GET /api/arbeitszeit:', error);
|
||||
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const input = parseArbeitszeitInput(await request.json());
|
||||
const entry = createArbeitszeit(input);
|
||||
return NextResponse.json(entry, { status: 201 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Datenbankfehler';
|
||||
console.error('POST /api/arbeitszeit:', error);
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { deleteSession } from '@/lib/session';
|
||||
|
||||
export async function POST() {
|
||||
await deleteSession();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { getPause, setPause } from '@/lib/repo';
|
||||
import { toNumber } from '@/lib/validate';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
try {
|
||||
return NextResponse.json({ pause: getPause() });
|
||||
} catch (error) {
|
||||
console.error('GET /api/settings:', error);
|
||||
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const pause = toNumber(body?.pause, NaN);
|
||||
if (isNaN(pause) || pause < 0 || pause > 24) {
|
||||
return NextResponse.json({ error: 'Ungültiger Pausenwert.' }, { status: 400 });
|
||||
}
|
||||
setPause(pause);
|
||||
return NextResponse.json({ pause });
|
||||
} catch (error) {
|
||||
console.error('PUT /api/settings:', error);
|
||||
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 1.5cm;
|
||||
}
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
table {
|
||||
font-size: 0.72rem !important;
|
||||
width: 95% !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Arbeitszeiterfassung',
|
||||
description: 'Erfassung der Arbeitszeit',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { verifyCredentials, getConfiguredUsername } from '@/lib/auth';
|
||||
import { createSession } from '@/lib/session';
|
||||
import { checkRateLimit } from '@/lib/ratelimit';
|
||||
|
||||
export async function login(
|
||||
_prevState: { error: string } | undefined,
|
||||
formData: FormData
|
||||
): Promise<{ error: string }> {
|
||||
const headersList = await headers();
|
||||
const ip =
|
||||
headersList.get('x-forwarded-for')?.split(',')[0].trim() ??
|
||||
headersList.get('x-real-ip') ??
|
||||
'unknown';
|
||||
|
||||
const { allowed, remainingMs } = checkRateLimit(ip);
|
||||
if (!allowed) {
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
return { error: `Zu viele Anmeldeversuche. Bitte ${minutes} Minute${minutes !== 1 ? 'n' : ''} warten.` };
|
||||
}
|
||||
|
||||
const username = (formData.get('username') as string)?.trim();
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
if (!username || !password) {
|
||||
return { error: 'Bitte Benutzer und Passwort eingeben.' };
|
||||
}
|
||||
|
||||
if (!verifyCredentials(username, password)) {
|
||||
return { error: 'Ungültiger Benutzer oder Passwort.' };
|
||||
}
|
||||
|
||||
await createSession({
|
||||
username: getConfiguredUsername(),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
redirect('/');
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { login } from './actions';
|
||||
import packageJson from '@/package.json';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [state, loginAction, isPending] = useActionState(login, undefined);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const version = packageJson.version;
|
||||
const buildDate =
|
||||
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white py-4 px-4">
|
||||
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF7EE]">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Arbeitszeiterfassung</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
|
||||
|
||||
<form action={loginAction} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Benutzer
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
defaultValue="Birgit"
|
||||
autoComplete="username"
|
||||
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-green-600 focus:outline-none text-sm"
|
||||
placeholder="Benutzer"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-green-600 focus:outline-none text-sm"
|
||||
placeholder="Passwort"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full py-2 px-4 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
|
||||
<div>
|
||||
<a href="mailto:rxf@gmx.de" className="text-green-700 hover:underline">
|
||||
rxf@gmx.de
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
Version {version} — {buildDate}
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSession } from '@/lib/session';
|
||||
import MainClient from './MainClient';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect('/login');
|
||||
|
||||
return <MainClient username={session.username} />;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ORTE, ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit';
|
||||
import { toDecimalHours, mehrarbeit, weekdayName, formatHours } from '@/lib/calc';
|
||||
import CustomSelect from './CustomSelect';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
interface Props {
|
||||
editEntry?: ArbeitszeitEintrag | null;
|
||||
pause: number;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function todayDate(): string {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
}
|
||||
|
||||
// Komma als Dezimaltrenner erlauben
|
||||
function toNum(raw: string): number {
|
||||
const n = parseFloat(raw.replace(',', '.'));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }: Props) {
|
||||
// Diese Komponente wird beim Wechsel von editEntry über `key` neu gemountet,
|
||||
// daher genügt eine einmalige Initialisierung aus editEntry (kein Sync-Effekt nötig).
|
||||
const [datum, setDatum] = useState(editEntry?.datum ?? todayDate());
|
||||
const [ort, setOrt] = useState<string>(editEntry?.ort ?? ORTE[0]);
|
||||
const [beginn, setBeginn] = useState(editEntry?.beginn ?? '09:00');
|
||||
const [ende, setEnde] = useState(editEntry?.ende ?? '17:00');
|
||||
const [reisezeit, setReisezeit] = useState(editEntry ? String(editEntry.reisezeit) : '0');
|
||||
const [vertrieb, setVertrieb] = useState(editEntry ? String(editEntry.vertriebsunterstuetzung) : '2');
|
||||
const [kommentar, setKommentar] = useState(editEntry?.kommentar ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const arbeitszeit = toDecimalHours(beginn, ende);
|
||||
const mehr = mehrarbeit(arbeitszeit, pause);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
if (arbeitszeit <= 0) {
|
||||
setError('Endzeit muss nach der Startzeit liegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const body = {
|
||||
datum,
|
||||
ort,
|
||||
beginn,
|
||||
ende,
|
||||
reisezeit: toNum(reisezeit),
|
||||
vertriebsunterstuetzung: toNum(vertrieb),
|
||||
kommentar,
|
||||
};
|
||||
|
||||
const url = editEntry ? `/api/arbeitszeit/${editEntry.id}` : '/api/arbeitszeit';
|
||||
const method = editEntry ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5';
|
||||
const numCls =
|
||||
'w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none';
|
||||
const wt = weekdayName(datum);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-3 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-3 bg-white"
|
||||
>
|
||||
{/* Datum + Wochentag / Ort */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="shrink-0">
|
||||
<label className={labelCls}>Datum</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={datum}
|
||||
onChange={(e) => e.target.value && setDatum(e.target.value)}
|
||||
required
|
||||
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-green-600 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm font-medium text-green-800 min-w-[5.5rem]">{wt}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className={labelCls}>Ort</label>
|
||||
<CustomSelect
|
||||
placeholder={ort}
|
||||
options={ORTE.map((o) => ({ value: o, label: o }))}
|
||||
onChange={setOrt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beginn / Ende / Reisezeit / Vertrieb */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="shrink-0">
|
||||
<label className={labelCls}>Beginn</label>
|
||||
<TimeInput value={beginn} onChange={setBeginn} className="w-24" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<label className={labelCls}>Ende</label>
|
||||
<TimeInput value={ende} onChange={setEnde} className="w-24" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<label className={labelCls}>Reisezeit (h)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={reisezeit}
|
||||
onChange={(e) => setReisezeit(e.target.value)}
|
||||
className={numCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<label className={labelCls}>Vertriebsunterst. (h)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={vertrieb}
|
||||
onChange={(e) => setVertrieb(e.target.value)}
|
||||
className={numCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kommentar */}
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
Kommentar
|
||||
<span className="ml-2 text-gray-400 font-normal text-xs">({kommentar.length}/500)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={kommentar}
|
||||
onChange={(e) => setKommentar(e.target.value.slice(0, 500))}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none resize-y"
|
||||
placeholder="Freier Text (max. 500 Zeichen)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Berechnungs-Vorschau */}
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm bg-[#EEF7EE] border border-green-200 rounded-lg px-3 py-2">
|
||||
<span>
|
||||
Arbeitszeit: <strong>{formatHours(arbeitszeit)} h</strong>
|
||||
</span>
|
||||
<span>
|
||||
Arbeitszeit Kunde: <strong>{formatHours(ARBEITSZEIT_KUNDE_FIX)} h</strong>
|
||||
</span>
|
||||
<span>
|
||||
Pause: <strong>{formatHours(pause)} h</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mehrarbeit: <strong className={mehr < 0 ? 'text-red-600' : 'text-green-800'}>{formatHours(mehr)} h</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && !editEntry && (
|
||||
<div className="bg-green-50 border border-green-300 text-green-700 px-3 py-2 rounded-lg text-sm">
|
||||
Eintrag gespeichert.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full sm:w-auto px-6 py-1.5 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||
>
|
||||
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
|
||||
</button>
|
||||
{editEntry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="w-full sm:w-auto px-6 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit';
|
||||
import { toDecimalHours, mehrarbeit, weekdayName, formatHours } from '@/lib/calc';
|
||||
|
||||
interface Props {
|
||||
pause: number;
|
||||
refreshKey: number;
|
||||
onEdit: (entry: ArbeitszeitEintrag) => void;
|
||||
limit?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||
|
||||
function currentMonth() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
||||
}
|
||||
function monthLabel(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
return `${MONATE[m - 1]} ${y}`;
|
||||
}
|
||||
function prevMonth(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
return m === 1 ? `${y - 1}-12` : `${y}-${pad(m - 1)}`;
|
||||
}
|
||||
function nextMonth(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
return m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
|
||||
}
|
||||
function formatDate(d: string) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d);
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : d;
|
||||
}
|
||||
|
||||
export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15, compact = false }: Props) {
|
||||
const [entries, setEntries] = useState<ArbeitszeitEintrag[]>([]);
|
||||
const [month, setMonth] = useState(compact ? '' : currentMonth());
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [deleteErr, setDeleteErr] = useState('');
|
||||
|
||||
// Lade-/Fehlerzustand wird aus dem Ergebnis abgeleitet, nicht synchron im Effekt gesetzt.
|
||||
const paramsKey = `${refreshKey}|${limit}|${month}`;
|
||||
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forKey: string } | null>(null);
|
||||
const loading = fetchResult?.forKey !== paramsKey;
|
||||
const fetchError = fetchResult?.forKey === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
|
||||
const error = fetchError || deleteErr;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const url =
|
||||
`/api/arbeitszeit?limit=${limit}&offset=0` + (month ? `&month=${encodeURIComponent(month)}` : '');
|
||||
fetch(url)
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setEntries(data.entries);
|
||||
setDeleteErr('');
|
||||
setFetchResult({ ok: true, forKey: paramsKey });
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forKey: paramsKey }); });
|
||||
return () => { cancelled = true; };
|
||||
}, [refreshKey, limit, month, paramsKey]);
|
||||
|
||||
async function confirmDelete(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/arbeitszeit/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
} catch {
|
||||
setDeleteErr('Fehler beim Löschen.');
|
||||
} finally {
|
||||
setDeleteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const cell = compact
|
||||
? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
|
||||
: 'px-3 py-2 border border-gray-200 text-gray-900';
|
||||
const head = compact
|
||||
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
|
||||
: 'px-3 py-2 border border-gray-300 text-gray-900';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-1 mb-3 print:hidden">
|
||||
<button onClick={() => setMonth((m) => prevMonth(m))} className="px-2 py-1 text-sm rounded-lg bg-[#86C28B] hover:bg-[#6FB075]">←</button>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
max={currentMonth()}
|
||||
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
|
||||
className="border border-green-600 rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-green-700"
|
||||
/>
|
||||
<button onClick={() => setMonth((m) => nextMonth(m))} disabled={month >= currentMonth()} className="px-2 py-1 text-sm rounded-lg bg-[#86C28B] hover:bg-[#6FB075] disabled:opacity-40 disabled:cursor-not-allowed">→</button>
|
||||
{month !== currentMonth() && (
|
||||
<button onClick={() => setMonth(currentMonth())} className="text-sm text-green-700 hover:underline ml-1">Aktueller Monat</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
|
||||
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
|
||||
<thead>
|
||||
<tr className="bg-gray-100 text-left">
|
||||
<th className={`${head} whitespace-nowrap`}>Datum</th>
|
||||
<th className={head}>Wochentag</th>
|
||||
<th className={head}>Ort</th>
|
||||
<th className={`${head} text-right whitespace-nowrap`}>Arbeitszeit Kunde</th>
|
||||
<th className={`${head} text-right`}>Mehrarbeit</th>
|
||||
<th className={`${head} text-right`}>Reisezeit</th>
|
||||
<th className={`${head} text-right whitespace-nowrap`}>Vertriebsunt.</th>
|
||||
<th className={head}>Kommentar</th>
|
||||
<th className={`${head} text-center print:hidden`}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-4 text-gray-500 text-sm text-center">
|
||||
{month ? `Keine Einträge für ${monthLabel(month)}.` : 'Keine Einträge vorhanden.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : entries.map((e) => {
|
||||
const mehr = mehrarbeit(toDecimalHours(e.beginn, e.ende), pause);
|
||||
return (
|
||||
<tr key={e.id} className="hover:bg-gray-50">
|
||||
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.datum)}</td>
|
||||
<td className={cell}>{weekdayName(e.datum)}</td>
|
||||
<td className={cell}>{e.ort}</td>
|
||||
<td className={`${cell} text-right`}>{formatHours(ARBEITSZEIT_KUNDE_FIX)}</td>
|
||||
<td className={`${cell} text-right ${mehr < 0 ? 'text-red-600' : ''}`}>{formatHours(mehr)}</td>
|
||||
<td className={`${cell} text-right`}>{formatHours(e.reisezeit)}</td>
|
||||
<td className={`${cell} text-right`}>{formatHours(e.vertriebsunterstuetzung)}</td>
|
||||
<td className={cell}>{e.kommentar}</td>
|
||||
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
|
||||
<button onClick={() => onEdit(e)} className="text-green-700 hover:text-green-900 mr-2 font-medium">✎</button>
|
||||
<button onClick={() => setDeleteId(e.id)} className="text-red-600 hover:text-red-800 font-medium">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteId !== null && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
|
||||
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setDeleteId(null)} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm">Abbrechen</button>
|
||||
<button onClick={() => confirmDelete(deleteId)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: SelectOption[];
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
keepOpen?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function CustomSelect({ options, placeholder, onChange, keepOpen = false, id }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleOutside);
|
||||
return () => document.removeEventListener('mousedown', handleOutside);
|
||||
}, [open]);
|
||||
|
||||
function select(value: string) {
|
||||
if (!keepOpen) setOpen(false);
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-green-600 focus:outline-none"
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-72 overflow-y-auto">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => select(opt.value)}
|
||||
className="w-full text-left px-4 py-2 text-base text-gray-900 hover:bg-green-50 active:bg-green-100 border-b border-gray-100 last:border-0 disabled:text-gray-400 disabled:bg-gray-50"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{keepOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full px-4 py-2 text-base font-medium text-center bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-b-lg border-t-2 border-gray-300"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
pause: number;
|
||||
onPauseChange: (pause: number) => void;
|
||||
}
|
||||
|
||||
export default function Einstellungen({ pause, onPauseChange }: Props) {
|
||||
const [raw, setRaw] = useState(String(pause).replace('.', ','));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'ok' | 'error'>('idle');
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setStatus('idle');
|
||||
const value = parseFloat(raw.replace(',', '.'));
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pause: value }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
onPauseChange(data.pause);
|
||||
setRaw(String(data.pause).replace('.', ','));
|
||||
setStatus('ok');
|
||||
setTimeout(() => setStatus('idle'), 3000);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="max-w-md space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Pause (dezimale Stunden)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.target.value)}
|
||||
className="w-32 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Wird in der Mehrarbeit-Berechnung abgezogen: Mehrarbeit = Arbeitszeit − 8 h − Pause.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-1.5 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
{status === 'ok' && <span className="text-green-700 text-sm">Gespeichert.</span>}
|
||||
{status === 'error' && <span className="text-red-600 text-sm">Fehler beim Speichern.</span>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string; // "HH:MM"
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
clearOnFocus?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function isValid(t: string): boolean {
|
||||
if (!/^\d{1,2}:\d{2}$/.test(t)) return false;
|
||||
const [h, m] = t.split(':').map(Number);
|
||||
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
|
||||
}
|
||||
|
||||
function normalize(t: string): string {
|
||||
const [h, m] = t.split(':').map(Number);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false, autoFocus = false }: Props) {
|
||||
const [local, setLocal] = useState(value);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Lokalen Bearbeitungstext zurücksetzen, wenn sich der Wert von außen ändert.
|
||||
// Bewusster Prop→State-Sync (Hybrid aus controlled/uncontrolled Eingabe).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLocal(value);
|
||||
setError(false);
|
||||
}, [value]);
|
||||
|
||||
function handleChange(raw: string) {
|
||||
setError(false);
|
||||
// Auto-insert colon after the 2nd digit (only when adding, not deleting)
|
||||
if (/^\d{2}$/.test(raw) && /^\d$/.test(local)) {
|
||||
setLocal(raw + ':');
|
||||
return;
|
||||
}
|
||||
setLocal(raw);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (clearOnFocus) {
|
||||
setLocal('');
|
||||
setError(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (local === '') {
|
||||
setLocal(value);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
const expanded = /^\d{1,2}:?$/.test(local) ? local.replace(/:$/, '') + ':00' : local;
|
||||
if (isValid(expanded)) {
|
||||
const norm = normalize(expanded);
|
||||
setLocal(norm);
|
||||
setError(false);
|
||||
onChange(norm);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={local}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
placeholder="HH:MM"
|
||||
maxLength={5}
|
||||
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
|
||||
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-green-600'
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
|
||||
Ungültig (00:00 – 23:59)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
arbeitszeit_app:
|
||||
image: docker.citysensor.de/arbeitszeit:latest
|
||||
container_name: arbeitszeit_app
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_PATH: /app/data/arbeitszeit.db
|
||||
volumes:
|
||||
- arbeitszeit_data:/app/data
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
||||
networks:
|
||||
- arbeitszeit_net
|
||||
|
||||
volumes:
|
||||
arbeitszeit_data:
|
||||
|
||||
networks:
|
||||
arbeitszeit_net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
REGISTRY="docker.citysensor.de"
|
||||
IMAGE_NAME="arbeitszeit"
|
||||
TAG="${1:-latest}"
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
BUILD_DATE=$(date +%d.%m.%Y)
|
||||
|
||||
echo "=========================================="
|
||||
echo "Arbeitszeit Deploy Script"
|
||||
echo "=========================================="
|
||||
echo "Registry: ${REGISTRY}"
|
||||
echo "Image: ${IMAGE_NAME}"
|
||||
echo "Tag: ${TAG}"
|
||||
echo "Build-Datum: ${BUILD_DATE}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo ">>> Login zu ${REGISTRY}..."
|
||||
docker login "${REGISTRY}"
|
||||
echo ""
|
||||
|
||||
echo ">>> Richte Multiplatform Builder ein..."
|
||||
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
|
||||
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
|
||||
fi
|
||||
docker buildx use multiplatform-builder
|
||||
echo ""
|
||||
|
||||
echo ">>> Baue Multiplatform Docker Image und pushe..."
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg BUILD_DATE="${BUILD_DATE}" \
|
||||
-t "${FULL_IMAGE}" \
|
||||
--push \
|
||||
.
|
||||
|
||||
if [ "${TAG}" != "latest" ]; then
|
||||
echo ">>> Tagge ${IMAGE_NAME} als :latest..."
|
||||
docker buildx imagetools create \
|
||||
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
|
||||
"${FULL_IMAGE}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Deploy erfolgreich abgeschlossen!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Auf dem Server ausführen:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
echo " docker compose -f docker-compose.prod.yml up -d"
|
||||
echo ""
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
arbeitszeit_app:
|
||||
image: docker.citysensor.de/arbeitszeit:latest
|
||||
container_name: arbeitszeit_app
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_PATH: /app/data/arbeitszeit.db
|
||||
volumes:
|
||||
- arbeitszeit_data:/app/data
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
||||
networks:
|
||||
- arbeitszeit_net
|
||||
|
||||
volumes:
|
||||
arbeitszeit_data:
|
||||
|
||||
networks:
|
||||
arbeitszeit_net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Single-User-Authentifizierung: Benutzername und Passwort fest in .env hinterlegt.
|
||||
|
||||
const DEFAULT_USERNAME = 'Birgit';
|
||||
|
||||
export function getConfiguredUsername(): string {
|
||||
return process.env.APP_USERNAME || DEFAULT_USERNAME;
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const expectedUser = getConfiguredUsername();
|
||||
const expectedPw = process.env.APP_PASSWORD;
|
||||
if (!expectedPw) throw new Error('APP_PASSWORD Umgebungsvariable ist nicht gesetzt!');
|
||||
// Benutzername case-insensitiv, Passwort exakt.
|
||||
return username.trim().toLowerCase() === expectedUser.toLowerCase() && password === expectedPw;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Reine Hilfsfunktionen für Datum-/Zeit-Berechnungen (ohne DB-Zugriff).
|
||||
|
||||
const WOCHENTAGE = [
|
||||
'Sonntag',
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
];
|
||||
|
||||
/** "HH:MM" → Minuten seit Mitternacht, oder null bei ungültiger Eingabe. */
|
||||
export function parseHM(hm: string): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(hm ?? '');
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
return h * 60 + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Differenz zwischen Beginn und Ende in dezimalen Stunden.
|
||||
* Gibt 0 zurück, wenn eine Zeit ungültig ist. Ende vor Beginn ⇒ 0.
|
||||
*/
|
||||
export function toDecimalHours(beginn: string, ende: string): number {
|
||||
const b = parseHM(beginn);
|
||||
const e = parseHM(ende);
|
||||
if (b === null || e === null) return 0;
|
||||
const diff = e - b;
|
||||
if (diff <= 0) return 0;
|
||||
return diff / 60;
|
||||
}
|
||||
|
||||
/** Mehrarbeit = Arbeitszeit − 8 Stunden − Pause (kann negativ sein). */
|
||||
export function mehrarbeit(arbeitszeitStd: number, pauseStd: number): number {
|
||||
return arbeitszeitStd - 8 - pauseStd;
|
||||
}
|
||||
|
||||
/** Deutscher Wochentagsname für ein "YYYY-MM-DD"-Datum, oder "" bei ungültig. */
|
||||
export function weekdayName(datum: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datum ?? '');
|
||||
if (!m) return '';
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return WOCHENTAGE[d.getDay()];
|
||||
}
|
||||
|
||||
/** Dezimalstunden deutsch formatieren, z.B. 0.5 → "0,5". */
|
||||
export function formatHours(value: number): string {
|
||||
return value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
function dbPath(): string {
|
||||
return process.env.DB_PATH || './data/arbeitszeit.db';
|
||||
}
|
||||
|
||||
/** Singleton-Zugriff auf die SQLite-Datenbank. Legt Datei + Schema beim ersten Aufruf an. */
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const path = dbPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
|
||||
db = new Database(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS arbeitszeit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
datum TEXT NOT NULL,
|
||||
ort TEXT NOT NULL,
|
||||
beginn TEXT NOT NULL,
|
||||
ende TEXT NOT NULL,
|
||||
reisezeit REAL NOT NULL DEFAULT 0,
|
||||
vertriebsunterstuetzung REAL NOT NULL DEFAULT 2,
|
||||
kommentar TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_arbeitszeit_datum ON arbeitszeit (datum);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Standard-Pause beim ersten Start setzen (0,5 h)
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES ('pause', '0.5')
|
||||
ON CONFLICT(key) DO NOTHING`
|
||||
).run();
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// In-memory rate limiter – funktioniert pro Prozess (single Docker container).
|
||||
// Erlaubt MAX_ATTEMPTS Versuche pro IP innerhalb WINDOW_MS Millisekunden.
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 15 * 60 * 1000; // 15 Minuten
|
||||
|
||||
interface Entry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
|
||||
// Aufräumen abgelaufener Einträge alle 5 Minuten
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt < now) store.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export function checkRateLimit(ip: string): { allowed: boolean; remainingMs: number } {
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
store.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, remainingMs: 0 };
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > MAX_ATTEMPTS) {
|
||||
return { allowed: false, remainingMs: entry.resetAt - now };
|
||||
}
|
||||
|
||||
return { allowed: true, remainingMs: 0 };
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { getDb } from './db';
|
||||
import type { ArbeitszeitEintrag, ArbeitszeitInput } from '@/types/arbeitszeit';
|
||||
|
||||
interface ListParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
month?: string; // "YYYY-MM"
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
entries: ArbeitszeitEintrag[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Einträge absteigend nach Datum (neueste zuerst), optional nach Monat gefiltert. */
|
||||
export function listArbeitszeit({ limit, offset, month }: ListParams): ListResult {
|
||||
const db = getDb();
|
||||
const where = month ? `WHERE substr(datum, 1, 7) = ?` : '';
|
||||
const args = month ? [month] : [];
|
||||
|
||||
const total = (
|
||||
db.prepare(`SELECT COUNT(*) AS c FROM arbeitszeit ${where}`).get(...args) as { c: number }
|
||||
).c;
|
||||
|
||||
const entries = db
|
||||
.prepare(
|
||||
`SELECT * FROM arbeitszeit ${where}
|
||||
ORDER BY datum DESC, beginn DESC, id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...args, limit, offset) as ArbeitszeitEintrag[];
|
||||
|
||||
return { entries, total };
|
||||
}
|
||||
|
||||
export function getArbeitszeit(id: number): ArbeitszeitEintrag | undefined {
|
||||
return getDb().prepare('SELECT * FROM arbeitszeit WHERE id = ?').get(id) as
|
||||
| ArbeitszeitEintrag
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function createArbeitszeit(input: ArbeitszeitInput): ArbeitszeitEintrag {
|
||||
const db = getDb();
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO arbeitszeit (datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar)
|
||||
VALUES (@datum, @ort, @beginn, @ende, @reisezeit, @vertriebsunterstuetzung, @kommentar)`
|
||||
)
|
||||
.run(input);
|
||||
return getArbeitszeit(Number(info.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function updateArbeitszeit(
|
||||
id: number,
|
||||
input: ArbeitszeitInput
|
||||
): ArbeitszeitEintrag | undefined {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE arbeitszeit SET
|
||||
datum = @datum, ort = @ort, beginn = @beginn, ende = @ende,
|
||||
reisezeit = @reisezeit, vertriebsunterstuetzung = @vertriebsunterstuetzung,
|
||||
kommentar = @kommentar
|
||||
WHERE id = @id`
|
||||
).run({ ...input, id });
|
||||
return getArbeitszeit(id);
|
||||
}
|
||||
|
||||
export function deleteArbeitszeit(id: number): boolean {
|
||||
return getDb().prepare('DELETE FROM arbeitszeit WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// --- Einstellungen -------------------------------------------------------
|
||||
|
||||
export function getPause(): number {
|
||||
const row = getDb().prepare(`SELECT value FROM settings WHERE key = 'pause'`).get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const v = row ? parseFloat(row.value) : 0.5;
|
||||
return isNaN(v) ? 0.5 : v;
|
||||
}
|
||||
|
||||
export function setPause(value: number): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES ('pause', @value)
|
||||
ON CONFLICT(key) DO UPDATE SET value = @value`
|
||||
)
|
||||
.run({ value: String(value) });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
|
||||
const SESSION_DURATION = 8 * 60 * 60 * 1000; // 8 Stunden
|
||||
|
||||
function getKey(): Uint8Array {
|
||||
const secretKey = process.env.AUTH_SECRET;
|
||||
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
|
||||
return new TextEncoder().encode(secretKey);
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
username: string;
|
||||
isAuthenticated: boolean;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
async function encrypt(payload: SessionData): Promise<string> {
|
||||
return new SignJWT(payload as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(new Date(payload.expiresAt))
|
||||
.sign(getKey());
|
||||
}
|
||||
|
||||
async function decrypt(token: string): Promise<SessionData | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
|
||||
return payload as unknown as SessionData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
|
||||
const expiresAt = Date.now() + SESSION_DURATION;
|
||||
const session: SessionData = { ...data, expiresAt };
|
||||
const encrypted = await encrypt(session);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
expires: expiresAt,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionData | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
|
||||
if (!cookie?.value) return null;
|
||||
const session = await decrypt(cookie.value);
|
||||
if (!session || session.expiresAt < Date.now()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function deleteSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ORTE, type ArbeitszeitInput, type Ort } from '@/types/arbeitszeit';
|
||||
import { parseHM } from './calc';
|
||||
|
||||
/**
|
||||
* Validiert und normalisiert einen JSON-Body zu einem ArbeitszeitInput.
|
||||
* Wirft Error mit deutscher Meldung bei ungültigen Daten.
|
||||
*/
|
||||
export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const datum = String(b.datum ?? '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datum)) throw new Error('Ungültiges Datum.');
|
||||
|
||||
const ort = String(b.ort ?? '') as Ort;
|
||||
if (!ORTE.includes(ort)) throw new Error('Ungültiger Ort.');
|
||||
|
||||
const beginn = String(b.beginn ?? '');
|
||||
const ende = String(b.ende ?? '');
|
||||
if (parseHM(beginn) === null) throw new Error('Ungültige Startzeit.');
|
||||
if (parseHM(ende) === null) throw new Error('Ungültige Endzeit.');
|
||||
|
||||
const reisezeit = toNumber(b.reisezeit, 0);
|
||||
const vertriebsunterstuetzung = toNumber(b.vertriebsunterstuetzung, 2);
|
||||
if (reisezeit < 0 || vertriebsunterstuetzung < 0) {
|
||||
throw new Error('Zeitwerte dürfen nicht negativ sein.');
|
||||
}
|
||||
|
||||
const kommentar = String(b.kommentar ?? '').slice(0, 500);
|
||||
|
||||
return { datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar };
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
if (value === '' || value === null || value === undefined) return fallback;
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.'));
|
||||
return isNaN(n) ? fallback : n;
|
||||
}
|
||||
|
||||
export { toNumber };
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Projektverzeichnis als Workspace-Root fixieren (mehrere Lockfiles im Baum)
|
||||
turbopack: {
|
||||
root: import.meta.dirname,
|
||||
},
|
||||
outputFileTracingRoot: import.meta.dirname,
|
||||
// better-sqlite3 ist ein natives Modul und darf nicht gebundlet werden
|
||||
serverExternalPackages: ['better-sqlite3'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7167
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "arbeitszeit",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"jose": "^6.2.2",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
|
||||
const secretKey = process.env.AUTH_SECRET;
|
||||
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
|
||||
const key = new TextEncoder().encode(secretKey);
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.startsWith('/login') || pathname.startsWith('/_next') || pathname.startsWith('/favicon')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
|
||||
|
||||
if (!cookie?.value) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(cookie.value, key, { algorithms: ['HS256'] });
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const ORTE = ['Kunde FFM', 'Homeoffice', 'Andrena'] as const;
|
||||
export type Ort = (typeof ORTE)[number];
|
||||
|
||||
// Fixe Arbeitszeit beim Kunden (Stunden) — feste Spalte in der Liste.
|
||||
export const ARBEITSZEIT_KUNDE_FIX = 8;
|
||||
|
||||
export interface ArbeitszeitEintrag {
|
||||
id: number;
|
||||
datum: string; // YYYY-MM-DD
|
||||
ort: Ort;
|
||||
beginn: string; // HH:MM
|
||||
ende: string; // HH:MM
|
||||
reisezeit: number; // dezimale Stunden
|
||||
vertriebsunterstuetzung: number; // dezimale Stunden
|
||||
kommentar: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Eingabe-/Update-Nutzlast (ohne id/created_at)
|
||||
export type ArbeitszeitInput = Omit<ArbeitszeitEintrag, 'id' | 'created_at'>;
|
||||
Reference in New Issue
Block a user