Compare commits
7 Commits
mai/ritchi
...
19bea8d058
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19bea8d058 | ||
|
|
661135d137 | ||
|
|
f8d97546e9 | ||
|
|
45605c803b | ||
|
|
e57b7c48ed | ||
|
|
d0197a091c | ||
|
|
fe97fed56d |
@@ -196,6 +196,46 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
|
||||
}
|
||||
|
||||
// UpdateSettings handles PUT /api/tenants/{id}/settings
|
||||
func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Only owners and admins can update settings
|
||||
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if role != "owner" && role != "admin" {
|
||||
jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var settings json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, tenant, http.StatusOK)
|
||||
}
|
||||
|
||||
// ListMembers handles GET /api/tenants/{id}/members
|
||||
func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := auth.UserFromContext(r.Context())
|
||||
|
||||
@@ -63,6 +63,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
|
||||
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
|
||||
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
|
||||
api.HandleFunc("PUT /api/tenants/{id}/settings", tenantH.UpdateSettings)
|
||||
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
|
||||
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
|
||||
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -173,6 +174,21 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
|
||||
return &ut, nil
|
||||
}
|
||||
|
||||
// UpdateSettings merges new settings into the tenant's existing settings JSONB.
|
||||
func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, settings json.RawMessage) (*models.Tenant, error) {
|
||||
var tenant models.Tenant
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`UPDATE tenants SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, name, slug, settings, created_at, updated_at`,
|
||||
settings, tenantID,
|
||||
).StructScan(&tenant)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update settings: %w", err)
|
||||
}
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
|
||||
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
|
||||
// Check if the user being removed is an owner
|
||||
|
||||
@@ -22,6 +22,9 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||
expose:
|
||||
- "3000"
|
||||
depends_on:
|
||||
|
||||
@@ -10,6 +10,10 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
RUN mkdir -p public
|
||||
RUN bun run build
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const nextConfig: NextConfig = {
|
||||
rewrites: async () => [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.API_URL || "http://localhost:8080"}/:path*`,
|
||||
destination: `${process.env.API_URL || "http://localhost:8080"}/api/:path*`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AIExtractPage() {
|
||||
|
||||
const { data: casesData } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<PaginatedResponse<Case>>("/api/cases"),
|
||||
queryFn: () => api.get<PaginatedResponse<Case>>("/cases"),
|
||||
});
|
||||
|
||||
const cases = casesData?.data ?? [];
|
||||
@@ -40,12 +40,12 @@ export default function AIExtractPage() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
response = await api.postFormData<ExtractionResponse>(
|
||||
"/api/ai/extract-deadlines",
|
||||
"/ai/extract-deadlines",
|
||||
formData,
|
||||
);
|
||||
} else {
|
||||
response = await api.post<ExtractionResponse>(
|
||||
"/api/ai/extract-deadlines",
|
||||
"/ai/extract-deadlines",
|
||||
{ text },
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default function AIExtractPage() {
|
||||
|
||||
try {
|
||||
const promises = deadlines.map((d) =>
|
||||
api.post(`/api/cases/${selectedCaseId}/deadlines`, {
|
||||
api.post(`/cases/${selectedCaseId}/deadlines`, {
|
||||
title: d.title,
|
||||
due_date: d.due_date ?? "",
|
||||
source: "ai_extraction",
|
||||
|
||||
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal file
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Settings, Calendar, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Tenant } from "@/lib/types";
|
||||
import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const {
|
||||
data: tenant,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tenant-current", tenantId],
|
||||
queryFn: () => api.get<Tenant>(`/api/tenants/${tenantId}`),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Einstellungen
|
||||
</h1>
|
||||
<Link
|
||||
href="/einstellungen/team"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Team verwalten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tenant Info */}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon={Settings}
|
||||
title="Fehler beim Laden"
|
||||
description="Einstellungen konnten nicht geladen werden."
|
||||
action={
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : tenant ? (
|
||||
<>
|
||||
{/* Kanzlei Info */}
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
|
||||
<Settings className="h-4 w-4 text-neutral-500" />
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
Kanzlei
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Name</p>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{tenant.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Slug</p>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{tenant.slug}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Erstellt am</p>
|
||||
<p className="text-sm text-neutral-700">
|
||||
{new Date(tenant.created_at).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CalDAV Settings */}
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
|
||||
<Calendar className="h-4 w-4 text-neutral-500" />
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
CalDAV-Synchronisierung
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CalDAVSettings tenant={tenant} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal file
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { TeamSettings } from "@/components/settings/TeamSettings";
|
||||
|
||||
export default function TeamPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/einstellungen"
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Users className="h-4 w-4 text-neutral-500" />
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Team verwalten
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||
<div className="border-b border-neutral-100 pb-3">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
Mitglieder
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">
|
||||
Benutzer einladen und Rollen verwalten
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TeamSettings />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function FristenPage() {
|
||||
|
||||
const { data: deadlines } = useQuery({
|
||||
queryKey: ["deadlines"],
|
||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
||||
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function TerminePage() {
|
||||
|
||||
const { data: appointments } = useQuery({
|
||||
queryKey: ["appointments"],
|
||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
||||
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||
});
|
||||
|
||||
function handleEdit(appointment: Appointment) {
|
||||
|
||||
@@ -54,16 +54,16 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
||||
|
||||
const { data: appointments, isLoading } = useQuery({
|
||||
queryKey: ["appointments"],
|
||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
||||
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||
});
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/appointments/${id}`),
|
||||
mutationFn: (id: string) => api.delete(`/appointments/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
toast.success("Termin geloscht");
|
||||
|
||||
@@ -41,7 +41,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +66,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<Appointment>("/api/appointments", body),
|
||||
api.post<Appointment>("/appointments", body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
@@ -89,7 +89,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/appointments/${appointment!.id}`),
|
||||
mutationFn: () => api.delete(`/appointments/${appointment!.id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
|
||||
@@ -39,14 +39,14 @@ export function DeadlineCalculator() {
|
||||
|
||||
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||
queryKey: ["proceeding-types"],
|
||||
queryFn: () => api.get<ProceedingType[]>("/api/proceeding-types"),
|
||||
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||
});
|
||||
|
||||
const calculateMutation = useMutation({
|
||||
mutationFn: (params: {
|
||||
proceeding_type: string;
|
||||
trigger_event_date: string;
|
||||
}) => api.post<CalculateResponse>("/api/deadlines/calculate", params),
|
||||
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||
});
|
||||
|
||||
function handleCalculate(e: React.FormEvent) {
|
||||
|
||||
@@ -54,12 +54,12 @@ export function DeadlineList() {
|
||||
|
||||
const { data: deadlines, isLoading } = useQuery({
|
||||
queryKey: ["deadlines"],
|
||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
||||
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||
});
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<Case[]>("/api/cases"),
|
||||
queryFn: () => api.get<Case[]>("/cases"),
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
|
||||
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal file
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
Tenant,
|
||||
CalDAVConfig,
|
||||
CalDAVSyncResponse,
|
||||
} from "@/lib/types";
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ value: 5, label: "5 Minuten" },
|
||||
{ value: 15, label: "15 Minuten" },
|
||||
{ value: 30, label: "30 Minuten" },
|
||||
{ value: 60, label: "1 Stunde" },
|
||||
{ value: 120, label: "2 Stunden" },
|
||||
{ value: 360, label: "6 Stunden" },
|
||||
];
|
||||
|
||||
const emptyConfig: CalDAVConfig = {
|
||||
url: "",
|
||||
username: "",
|
||||
password: "",
|
||||
calendar_path: "",
|
||||
sync_enabled: false,
|
||||
sync_interval_minutes: 15,
|
||||
};
|
||||
|
||||
export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
|
||||
const queryClient = useQueryClient();
|
||||
const existing = (tenant.settings as Record<string, unknown>)?.caldav as
|
||||
| Partial<CalDAVConfig>
|
||||
| undefined;
|
||||
const [config, setConfig] = useState<CalDAVConfig>({
|
||||
...emptyConfig,
|
||||
...existing,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Reset form when tenant changes
|
||||
useEffect(() => {
|
||||
const caldav = (tenant.settings as Record<string, unknown>)?.caldav as
|
||||
| Partial<CalDAVConfig>
|
||||
| undefined;
|
||||
setConfig({ ...emptyConfig, ...caldav });
|
||||
}, [tenant]);
|
||||
|
||||
// Fetch sync status
|
||||
const { data: syncStatus } = useQuery({
|
||||
queryKey: ["caldav-status"],
|
||||
queryFn: () => api.get<CalDAVSyncResponse>("/caldav/status"),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Save settings
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (cfg: CalDAVConfig) => {
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
return api.put<Tenant>(`/api/tenants/${tenantId}/settings`, {
|
||||
caldav: cfg,
|
||||
});
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(["tenant-current"], updated);
|
||||
toast.success("CalDAV-Einstellungen gespeichert");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Speichern der CalDAV-Einstellungen");
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger sync
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post<CalDAVSyncResponse>("/caldav/sync"),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["caldav-status"] });
|
||||
if (result.status === "ok") {
|
||||
toast.success(
|
||||
`Synchronisierung abgeschlossen: ${result.sync.items_pushed} gesendet, ${result.sync.items_pulled} empfangen`
|
||||
);
|
||||
} else {
|
||||
toast.error("Synchronisierung mit Fehlern abgeschlossen");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler bei der Synchronisierung");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(config);
|
||||
};
|
||||
|
||||
const hasConfig = config.url && config.username && config.password;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* CalDAV Configuration Form */}
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
CalDAV-Server URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.url}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, url: e.target.value }))
|
||||
}
|
||||
placeholder="https://dav.example.com/dav"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, username: e.target.value }))
|
||||
}
|
||||
placeholder="user@example.com"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={config.password}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, password: e.target.value }))
|
||||
}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 pr-16 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
{showPassword ? "Verbergen" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
Kalender-Pfad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.calendar_path}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, calendar_path: e.target.value }))
|
||||
}
|
||||
placeholder="/dav/calendars/user/default/"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Pfad zum Kalender auf dem CalDAV-Server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Settings */}
|
||||
<div className="flex flex-col gap-4 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.sync_enabled}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({ ...c, sync_enabled: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-400"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
Automatische Synchronisierung
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-neutral-500">Intervall:</label>
|
||||
<select
|
||||
value={config.sync_interval_minutes}
|
||||
onChange={(e) =>
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
sync_interval_minutes: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
disabled={!config.sync_enabled}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 disabled:opacity-50"
|
||||
>
|
||||
{SYNC_INTERVALS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-neutral-200 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? "Speichern..." : "Speichern"}
|
||||
</button>
|
||||
|
||||
{hasConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-4 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{syncMutation.isPending
|
||||
? "Synchronisiere..."
|
||||
: "Jetzt synchronisieren"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sync Status */}
|
||||
{syncStatus && syncStatus.last_sync_at !== null && (
|
||||
<SyncStatusDisplay data={syncStatus} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncStatusDisplay({ data }: { data: CalDAVSyncResponse }) {
|
||||
const hasErrors = data.sync?.errors && data.sync.errors.length > 0;
|
||||
const lastSync = data.sync?.last_sync_at
|
||||
? new Date(data.sync.last_sync_at)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
hasErrors
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-emerald-200 bg-emerald-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{hasErrors ? (
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${hasErrors ? "text-red-800" : "text-emerald-800"}`}
|
||||
>
|
||||
{hasErrors
|
||||
? "Letzte Synchronisierung mit Fehlern"
|
||||
: "Letzte Synchronisierung erfolgreich"}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{lastSync && (
|
||||
<span className="inline-flex items-center gap-1 text-neutral-600">
|
||||
<Clock className="h-3 w-3" />
|
||||
{lastSync.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
{lastSync.toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-neutral-600">
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
{data.sync.items_pushed} gesendet, {data.sync.items_pulled}{" "}
|
||||
empfangen
|
||||
</span>
|
||||
{data.sync.sync_duration && (
|
||||
<span className="text-neutral-400">
|
||||
Dauer: {data.sync.sync_duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasErrors && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{data.sync.errors!.map((err, i) => (
|
||||
<p key={i} className="text-xs text-red-700">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/settings/TeamSettings.tsx
Normal file
167
frontend/src/components/settings/TeamSettings.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { UserTenant } from "@/lib/types";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
|
||||
owner: { label: "Eigentümer", icon: Crown },
|
||||
admin: { label: "Administrator", icon: Shield },
|
||||
member: { label: "Mitglied", icon: User },
|
||||
};
|
||||
|
||||
export function TeamSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState("member");
|
||||
|
||||
const {
|
||||
data: members,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["tenant-members", tenantId],
|
||||
queryFn: () =>
|
||||
api.get<UserTenant[]>(`/api/tenants/${tenantId}/members`),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: { email: string; role: string }) =>
|
||||
api.post(`/api/tenants/${tenantId}/invite`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
toast.success("Benutzer eingeladen");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Einladen");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
api.delete(`/api/tenants/${tenantId}/members/${userId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
toast.success("Mitglied entfernt");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Entfernen");
|
||||
},
|
||||
});
|
||||
|
||||
const handleInvite = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
inviteMutation.mutate({ email: email.trim(), role });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Fehler beim Laden"
|
||||
description="Team-Mitglieder konnten nicht geladen werden."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invite Form */}
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending || !email.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Members List */}
|
||||
{members && members.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||
{members.map((member, i) => {
|
||||
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
|
||||
const RoleIcon = roleInfo.icon;
|
||||
return (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
i < members.length - 1 ? "border-b border-neutral-100" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100">
|
||||
<RoleIcon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{member.user_id.slice(0, 8)}...
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== "owner" && (
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Noch keine Mitglieder"
|
||||
description="Laden Sie Teammitglieder per E-Mail ein."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,30 @@ export interface CalculateResponse {
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface CalDAVConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
calendar_path: string;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_minutes: number;
|
||||
}
|
||||
|
||||
export interface CalDAVSyncStatus {
|
||||
tenant_id: string;
|
||||
last_sync_at: string;
|
||||
items_pushed: number;
|
||||
items_pulled: number;
|
||||
errors?: string[];
|
||||
sync_duration: string;
|
||||
}
|
||||
|
||||
export interface CalDAVSyncResponse {
|
||||
status: string;
|
||||
sync: CalDAVSyncStatus;
|
||||
last_sync_at?: null;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
status: number;
|
||||
|
||||
@@ -55,6 +55,6 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
"/((?!api/|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user