mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
added document uploading
This commit is contained in:
parent
89ab4a713a
commit
4d42a172a5
22 changed files with 2369 additions and 17 deletions
20
app/admin/clients/[id]/storage/page.tsx
Normal file
20
app/admin/clients/[id]/storage/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Params } from "@/modules/shared/types/user"
|
||||||
|
import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel"
|
||||||
|
|
||||||
|
type SearchParams = Promise<{ folder?: string | string[] }>
|
||||||
|
|
||||||
|
const StorageAdminPage = async ({ params, searchParams }: { params: Params; searchParams: SearchParams }) => {
|
||||||
|
const { id } = await params
|
||||||
|
const { folder } = await searchParams
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentsPanel
|
||||||
|
profileId={id}
|
||||||
|
isAdmin
|
||||||
|
basePath={`/admin/clients/${id}/storage`}
|
||||||
|
folderId={typeof folder === "string" ? folder : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StorageAdminPage
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { verifySession } from "@/lib/supabase/dal"
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import DashboardView from "@/modules/admin/ui/views/dashboard-view";
|
||||||
|
|
||||||
const AdminDashboard = async () => {
|
const AdminDashboard = async () => {
|
||||||
const session = await verifySession();
|
const session = await verifySession();
|
||||||
if (!session.isAdmin) redirect("/client/dashboard");
|
if (!session.isAdmin) redirect("/client/dashboard");
|
||||||
|
|
||||||
return (
|
return <DashboardView name={session.name} />
|
||||||
<div>
|
|
||||||
<p>{session.email}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminDashboard
|
export default AdminDashboard
|
||||||
|
|
|
||||||
12
app/admin/settings/page.tsx
Normal file
12
app/admin/settings/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import SettingsView from "@/modules/admin/ui/views/settings-view"
|
||||||
|
|
||||||
|
const AdminSettings = async () => {
|
||||||
|
const session = await verifySession()
|
||||||
|
if (!session.isAdmin) redirect("/client/dashboard")
|
||||||
|
|
||||||
|
return <SettingsView name={session.name} email={session.email} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSettings
|
||||||
20
app/client/storage/page.tsx
Normal file
20
app/client/storage/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel"
|
||||||
|
|
||||||
|
type SearchParams = Promise<{ folder?: string | string[] }>
|
||||||
|
|
||||||
|
const StoragePage = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||||
|
const session = await verifySession()
|
||||||
|
const { folder } = await searchParams
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentsPanel
|
||||||
|
profileId={session.userId}
|
||||||
|
isAdmin={false}
|
||||||
|
basePath="/client/storage"
|
||||||
|
folderId={typeof folder === "string" ? folder : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StoragePage
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Dokumente: Upload-Grenzen und Absicherung des Storage-Pfads
|
||||||
|
|
||||||
|
-- Ein Dokument darf nur auf eine Datei im Storage-Ordner des eigenen Clients zeigen.
|
||||||
|
-- Sonst könnte ein Client einen Datensatz auf den Pfad eines anderen Clients anlegen.
|
||||||
|
alter table public.documents
|
||||||
|
add constraint documents_storage_path_matches_client
|
||||||
|
check (split_part(storage_path, '/', 1) = client_id::text);
|
||||||
|
|
||||||
|
-- Max. 50 MB pro Datei (Supabase-Standardlimit im Free-Plan)
|
||||||
|
update storage.buckets
|
||||||
|
set file_size_limit = 52428800
|
||||||
|
where id = 'client-documents';
|
||||||
90
modules/admin/actions/manage-client.ts
Normal file
90
modules/admin/actions/manage-client.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import * as z from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
import { generatePassword } from "@/lib/generate-password";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||||
|
|
||||||
|
export type ResetPasswordState = { error: string } | { success: true; password: string } | undefined;
|
||||||
|
export type UpdateClientState = { error: string } | { success: true } | undefined;
|
||||||
|
|
||||||
|
const IdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
// Nur Admins, und nur Konten mit Rolle "client" – Admin-Konten lassen sich hierüber nicht ändern.
|
||||||
|
async function assertClientTarget(profileId: string) {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAdmin) return { error: "Keine Berechtigung." };
|
||||||
|
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase.from("profiles").select("role").eq("id", profileId).maybeSingle();
|
||||||
|
if (data?.role !== "client") return { error: "Client nicht gefunden." };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResetSchema = z.discriminatedUnion("mode", [
|
||||||
|
z.object({ mode: z.literal("generate") }),
|
||||||
|
z.object({
|
||||||
|
mode: z.literal("manual"),
|
||||||
|
password: z.string().min(MIN_PASSWORD_LENGTH, { error: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function resetClientPassword(_prev: ResetPasswordState, formData: FormData): Promise<ResetPasswordState> {
|
||||||
|
const profileId = String(formData.get("profileId") ?? "");
|
||||||
|
const denied = await assertClientTarget(profileId);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const parsed = ResetSchema.safeParse({
|
||||||
|
mode: formData.get("mode"),
|
||||||
|
password: formData.get("password") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||||
|
|
||||||
|
const password = parsed.data.mode === "generate" ? generatePassword() : parsed.data.password;
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { error } = await admin.auth.admin.updateUserById(profileId, { password });
|
||||||
|
if (error) return { error: "Passwort konnte nicht geändert werden." };
|
||||||
|
|
||||||
|
return { success: true, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateSchema = z.object({
|
||||||
|
name: z.string().trim().min(3, { error: "Mindestens drei Zeichen eingeben." }),
|
||||||
|
email: z.email({ error: "Bitte eine gültige Email eingeben." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateClientAccount(_prev: UpdateClientState, formData: FormData): Promise<UpdateClientState> {
|
||||||
|
const profileId = String(formData.get("profileId") ?? "");
|
||||||
|
const denied = await assertClientTarget(profileId);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const parsed = UpdateSchema.safeParse({ name: formData.get("name"), email: formData.get("email") });
|
||||||
|
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||||
|
|
||||||
|
const email = parsed.data.email.toLowerCase();
|
||||||
|
|
||||||
|
// Login-Email lebt in auth.users; ohne email_confirm würde Supabase erst eine Bestätigungsmail verlangen.
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { error: authError } = await admin.auth.admin.updateUserById(profileId, {
|
||||||
|
email,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { name: parsed.data.name },
|
||||||
|
});
|
||||||
|
if (authError) {
|
||||||
|
return { error: authError.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht aktualisiert werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.from("profiles").update({ name: parsed.data.name, email }).eq("id", profileId);
|
||||||
|
if (error) return { error: "Login-Email wurde geändert, das Profil aber nicht. Bitte erneut speichern." };
|
||||||
|
|
||||||
|
revalidatePath("/admin/clients");
|
||||||
|
revalidatePath(`/admin/clients/${profileId}`, "layout");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
155
modules/admin/actions/settings.ts
Normal file
155
modules/admin/actions/settings.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import * as z from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||||
|
|
||||||
|
export type SettingsState = { error: string } | { success: true; message?: string } | undefined;
|
||||||
|
|
||||||
|
const IdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await verifySession();
|
||||||
|
return session.isAdmin ? session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstIssue(error: z.ZodError) {
|
||||||
|
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOwnProfile(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
if (!session) return { error: "Keine Berechtigung." };
|
||||||
|
|
||||||
|
const name = z.string().trim().min(2, { error: "Mindestens zwei Zeichen eingeben." }).max(100).safeParse(formData.get("name"));
|
||||||
|
if (!name.success) return { error: firstIssue(name.error) };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId);
|
||||||
|
if (error) return { error: "Name konnte nicht gespeichert werden." };
|
||||||
|
|
||||||
|
await supabase.auth.updateUser({ data: { name: name.data } });
|
||||||
|
|
||||||
|
revalidatePath("/admin", "layout");
|
||||||
|
return { success: true, message: "Name gespeichert." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasswordSchema = z
|
||||||
|
.object({
|
||||||
|
current: z.string().min(1, { error: "Bitte das aktuelle Passwort eingeben." }),
|
||||||
|
next: z.string().min(MIN_PASSWORD_LENGTH, { error: `Das neue Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72),
|
||||||
|
confirm: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.next === data.confirm, { path: ["confirm"], error: "Die Passwörter stimmen nicht überein." })
|
||||||
|
.refine((data) => data.next !== data.current, { path: ["next"], error: "Das neue Passwort muss sich vom alten unterscheiden." });
|
||||||
|
|
||||||
|
export async function changeOwnPassword(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
if (!session) return { error: "Keine Berechtigung." };
|
||||||
|
|
||||||
|
const parsed = PasswordSchema.safeParse({
|
||||||
|
current: formData.get("current"),
|
||||||
|
next: formData.get("next"),
|
||||||
|
confirm: formData.get("confirm"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: firstIssue(parsed.error) };
|
||||||
|
|
||||||
|
// Aktuelles Passwort mit einem separaten Client prüfen, damit die Session des Admins unberührt bleibt.
|
||||||
|
const verifier = createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||||
|
{ auth: { persistSession: false, autoRefreshToken: false } }
|
||||||
|
);
|
||||||
|
const { error: signInError } = await verifier.auth.signInWithPassword({ email: session.email, password: parsed.data.current });
|
||||||
|
if (signInError) return { error: "Das aktuelle Passwort ist falsch." };
|
||||||
|
await verifier.auth.signOut({ scope: "local" });
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.updateUser({ password: parsed.data.next });
|
||||||
|
if (error) return { error: "Passwort konnte nicht geändert werden." };
|
||||||
|
|
||||||
|
return { success: true, message: "Passwort geändert." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, { error: "Name ist erforderlich." }).max(200),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(500)
|
||||||
|
.transform((value) => (value === "" ? null : value)),
|
||||||
|
unitPrice: z.number({ error: "Bitte einen gültigen Preis eingeben." }).min(0).lt(1e10).multipleOf(0.01, { error: "Preis: maximal 2 Nachkommastellen." }),
|
||||||
|
taxRate: z.number({ error: "Bitte eine gültige MwSt eingeben." }).min(0).max(100).multipleOf(0.01),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseDecimal(value: FormDataEntryValue | null) {
|
||||||
|
const trimmed = String(value ?? "").trim();
|
||||||
|
if (trimmed === "") return Number.NaN;
|
||||||
|
return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||||
|
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||||
|
|
||||||
|
const parsed = TemplateSchema.safeParse({
|
||||||
|
name: formData.get("name") ?? "",
|
||||||
|
description: formData.get("description") ?? "",
|
||||||
|
unitPrice: parseDecimal(formData.get("unitPrice")),
|
||||||
|
taxRate: parseDecimal(formData.get("taxRate")),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: firstIssue(parsed.error) };
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
unit_price: parsed.data.unitPrice,
|
||||||
|
tax_rate: parsed.data.taxRate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
if (id === "") {
|
||||||
|
const { error } = await supabase.from("product_templates").insert(values);
|
||||||
|
if (error) return { error: "Vorlage konnte nicht gespeichert werden." };
|
||||||
|
} else {
|
||||||
|
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||||
|
const { data, error } = await supabase.from("product_templates").update(values).eq("id", id).select("id");
|
||||||
|
if (error || !data?.length) return { error: "Vorlage konnte nicht gespeichert werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductTemplateActive(id: string, isActive: boolean): Promise<SettingsState> {
|
||||||
|
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||||
|
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("product_templates")
|
||||||
|
.update({ is_active: Boolean(isActive) })
|
||||||
|
.eq("id", id)
|
||||||
|
.select("id");
|
||||||
|
if (error || !data?.length) return { error: "Vorlage konnte nicht geändert werden." };
|
||||||
|
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehende Rechnungen bleiben unverändert: invoice_items speichern Bezeichnung und Preis als Snapshot.
|
||||||
|
export async function deleteProductTemplate(id: string): Promise<SettingsState> {
|
||||||
|
if (!(await requireAdmin())) return { error: "Keine Berechtigung." };
|
||||||
|
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.from("product_templates").delete().eq("id", id).select("id");
|
||||||
|
if (error || !data?.length) return { error: "Vorlage konnte nicht gelöscht werden." };
|
||||||
|
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
1
modules/admin/lib/constants.ts
Normal file
1
modules/admin/lib/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const MIN_PASSWORD_LENGTH = 10;
|
||||||
227
modules/admin/queries/dashboard.ts
Normal file
227
modules/admin/queries/dashboard.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import "server-only"
|
||||||
|
import Decimal from "decimal.js"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { berlinToday } from "@/modules/shared/lib/info-format"
|
||||||
|
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
|
||||||
|
import { PRODUCT_STATUS_LABELS, type ProductStatus } from "@/modules/shared/schemas/info"
|
||||||
|
|
||||||
|
const RECENT_UPLOAD_ROWS = 100
|
||||||
|
const MAX_UPLOAD_GROUPS = 8
|
||||||
|
const MAX_TASKS = 8
|
||||||
|
|
||||||
|
export type AttentionItem = {
|
||||||
|
key: string
|
||||||
|
kind: "overdue" | "outage" | "maintenance" | "check" | "stammdaten" | "terms"
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadGroup = {
|
||||||
|
key: string
|
||||||
|
clientName: string
|
||||||
|
uploaderName: string
|
||||||
|
folderName: string
|
||||||
|
count: number
|
||||||
|
latestAt: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardTask = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
clientName: string
|
||||||
|
since: string
|
||||||
|
status: "in_progress" | "planned"
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardData = {
|
||||||
|
openCount: number
|
||||||
|
openTotal: number
|
||||||
|
overdueCount: number
|
||||||
|
overdueTotal: number
|
||||||
|
paidThisMonthTotal: number
|
||||||
|
clientCount: number
|
||||||
|
clientsWithIssues: number
|
||||||
|
attention: AttentionItem[]
|
||||||
|
uploads: UploadGroup[]
|
||||||
|
tasks: DashboardTask[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(fromIso: string, toIso: string) {
|
||||||
|
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(values: (number | string | null)[]) {
|
||||||
|
return values.reduce<Decimal>((total, value) => total.plus(new Decimal(value ?? 0)), new Decimal(0)).toNumber()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins lesen per RLS (is_admin) alle Clients – daher reicht der normale Server-Client.
|
||||||
|
export async function getDashboardData(): Promise<DashboardData> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const today = berlinToday()
|
||||||
|
const currentMonth = today.slice(0, 7)
|
||||||
|
|
||||||
|
const [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes] = await Promise.all([
|
||||||
|
supabase.from("profiles").select("id, name, client_id, terms_accepted_at").eq("role", "client").order("name"),
|
||||||
|
supabase.from("clients").select("id, company_name"),
|
||||||
|
supabase
|
||||||
|
.from("invoices_overview")
|
||||||
|
.select("id, client_id, invoice_number, status, display_status, gross_total, due_date, paid_at")
|
||||||
|
.neq("status", "cancelled"),
|
||||||
|
supabase.from("client_products").select("id, client_id, name, status, status_message"),
|
||||||
|
supabase.from("product_check_summary").select("product_id, ok, error"),
|
||||||
|
supabase
|
||||||
|
.from("product_tasks")
|
||||||
|
.select("id, client_id, name, status, since")
|
||||||
|
.in("status", ["in_progress", "planned"])
|
||||||
|
.order("status")
|
||||||
|
.order("since")
|
||||||
|
.limit(MAX_TASKS),
|
||||||
|
supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey(id, name, role)")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(RECENT_UPLOAD_ROWS),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const result of [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes]) {
|
||||||
|
if (result.error) throw new Error("Dashboard konnte nicht geladen werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = profilesRes.data!
|
||||||
|
const companyById = new Map(companiesRes.data!.map((row) => [row.id as string, row.company_name as string]))
|
||||||
|
const profileByClient = new Map<string, { id: string; name: string }>()
|
||||||
|
for (const profile of profiles) {
|
||||||
|
if (profile.client_id && !profileByClient.has(profile.client_id)) {
|
||||||
|
profileByClient.set(profile.client_id, { id: profile.id, name: profile.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientLabel = (clientId: string) =>
|
||||||
|
companyById.get(clientId) ?? profileByClient.get(clientId)?.name ?? "Unbekannter Client"
|
||||||
|
const clientBase = (clientId: string) => {
|
||||||
|
const profile = profileByClient.get(clientId)
|
||||||
|
return profile ? `/admin/clients/${profile.id}` : "/admin/clients"
|
||||||
|
}
|
||||||
|
|
||||||
|
const attention: AttentionItem[] = []
|
||||||
|
|
||||||
|
// Rechnungen
|
||||||
|
const invoices = invoicesRes.data!
|
||||||
|
const open = invoices.filter((invoice) => invoice.status === "pending")
|
||||||
|
const overdue = open
|
||||||
|
.filter((invoice) => invoice.display_status === "overdue")
|
||||||
|
.sort((a, b) => a.due_date.localeCompare(b.due_date))
|
||||||
|
const paidThisMonth = invoices.filter((invoice) => invoice.status === "paid" && invoice.paid_at?.startsWith(currentMonth))
|
||||||
|
|
||||||
|
for (const invoice of overdue) {
|
||||||
|
const days = daysBetween(invoice.due_date, today)
|
||||||
|
attention.push({
|
||||||
|
key: `invoice-${invoice.id}`,
|
||||||
|
kind: "overdue",
|
||||||
|
title: `${invoice.invoice_number} ist überfällig`,
|
||||||
|
detail: `${clientLabel(invoice.client_id)} · ${formatCurrency(Number(invoice.gross_total))} · seit ${days} ${days === 1 ? "Tag" : "Tagen"}`,
|
||||||
|
href: `${clientBase(invoice.client_id)}/billing`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Systeme
|
||||||
|
const lastCheckByProduct = new Map(checksRes.data!.map((row) => [row.product_id as string, row]))
|
||||||
|
const clientsWithIssues = new Set<string>()
|
||||||
|
|
||||||
|
for (const product of productsRes.data!) {
|
||||||
|
const status = product.status as ProductStatus
|
||||||
|
const lastCheck = lastCheckByProduct.get(product.id)
|
||||||
|
const failedCheck = lastCheck?.ok === false
|
||||||
|
|
||||||
|
if (status === "offline" || failedCheck) clientsWithIssues.add(product.client_id)
|
||||||
|
if (status === "online" && !failedCheck) continue
|
||||||
|
|
||||||
|
const kind: AttentionItem["kind"] = status === "offline" ? "outage" : status === "maintenance" ? "maintenance" : "check"
|
||||||
|
attention.push({
|
||||||
|
key: `product-${product.id}`,
|
||||||
|
kind,
|
||||||
|
title:
|
||||||
|
kind === "check"
|
||||||
|
? `${product.name}: letzte Prüfung fehlgeschlagen`
|
||||||
|
: `${product.name}: ${PRODUCT_STATUS_LABELS[status]}`,
|
||||||
|
detail: [clientLabel(product.client_id), kind === "check" ? lastCheck?.error : product.status_message]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
href: clientBase(product.client_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
for (const profile of profiles) {
|
||||||
|
if (!profile.client_id) {
|
||||||
|
attention.push({
|
||||||
|
key: `stammdaten-${profile.id}`,
|
||||||
|
kind: "stammdaten",
|
||||||
|
title: `${profile.name}: Stammdaten fehlen`,
|
||||||
|
detail: "Ohne Stammdaten sind Rechnungen, Dokumente und Status nicht nutzbar.",
|
||||||
|
href: `/admin/clients/${profile.id}/data`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!profile.terms_accepted_at) {
|
||||||
|
attention.push({
|
||||||
|
key: `terms-${profile.id}`,
|
||||||
|
kind: "terms",
|
||||||
|
title: `${profile.name}: AGB noch nicht akzeptiert`,
|
||||||
|
detail: "Wird beim nächsten Login des Clients abgefragt.",
|
||||||
|
href: `/admin/clients/${profile.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority: Record<AttentionItem["kind"], number> = { outage: 0, overdue: 1, check: 2, maintenance: 3, stammdaten: 4, terms: 5 }
|
||||||
|
attention.sort((a, b) => priority[a.kind] - priority[b.kind])
|
||||||
|
|
||||||
|
// Uploads von Clients, zusammengefasst pro Person + Ordner
|
||||||
|
const groups = new Map<string, UploadGroup>()
|
||||||
|
for (const doc of documentsRes.data!) {
|
||||||
|
const uploader = doc.uploader as unknown as { id: string; name: string; role: string } | null
|
||||||
|
if (!uploader || uploader.role !== "client") continue
|
||||||
|
|
||||||
|
const key = `${uploader.id}-${doc.folder_id}`
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (groups.size >= MAX_UPLOAD_GROUPS) continue
|
||||||
|
|
||||||
|
const folder = doc.folders as unknown as { name: string } | null
|
||||||
|
groups.set(key, {
|
||||||
|
key,
|
||||||
|
clientName: clientLabel(doc.client_id),
|
||||||
|
uploaderName: uploader.name,
|
||||||
|
folderName: folder?.name ?? "Ohne Ordner",
|
||||||
|
count: 1,
|
||||||
|
latestAt: doc.created_at,
|
||||||
|
href: `/admin/clients/${uploader.id}/storage${doc.folder_id ? `?folder=${doc.folder_id}` : ""}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openCount: open.length,
|
||||||
|
openTotal: sum(open.map((invoice) => invoice.gross_total)),
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
overdueTotal: sum(overdue.map((invoice) => invoice.gross_total)),
|
||||||
|
paidThisMonthTotal: sum(paidThisMonth.map((invoice) => invoice.gross_total)),
|
||||||
|
clientCount: profiles.length,
|
||||||
|
clientsWithIssues: clientsWithIssues.size,
|
||||||
|
attention,
|
||||||
|
uploads: [...groups.values()],
|
||||||
|
tasks: tasksRes.data!.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
clientName: clientLabel(task.client_id),
|
||||||
|
since: task.since,
|
||||||
|
status: task.status,
|
||||||
|
href: clientBase(task.client_id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
31
modules/admin/queries/settings.ts
Normal file
31
modules/admin/queries/settings.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import "server-only"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
|
||||||
|
export type AdminProductTemplate = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
unitPrice: number
|
||||||
|
taxRate: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllProductTemplates(): Promise<AdminProductTemplate[]> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("product_templates")
|
||||||
|
.select("id, name, description, unit_price, tax_rate, is_active")
|
||||||
|
.order("is_active", { ascending: false })
|
||||||
|
.order("name")
|
||||||
|
|
||||||
|
if (error) throw new Error("Produktvorlagen konnten nicht geladen werden.")
|
||||||
|
|
||||||
|
return data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
unitPrice: Number(row.unit_price),
|
||||||
|
taxRate: Number(row.tax_rate),
|
||||||
|
isActive: row.is_active,
|
||||||
|
}))
|
||||||
|
}
|
||||||
169
modules/admin/ui/components/client-actions-menu.tsx
Normal file
169
modules/admin/ui/components/client-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { KeyRoundIcon, MoreHorizontalIcon, PencilIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { resetClientPassword, updateClientAccount } from "@/modules/admin/actions/manage-client";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||||
|
import { CopyField } from "./copy-field";
|
||||||
|
|
||||||
|
type Client = { id: string; name: string; email: string };
|
||||||
|
|
||||||
|
function ResetPasswordContent({ client, onDone }: { client: Client; onDone: () => void }) {
|
||||||
|
const [mode, setMode] = useState<"generate" | "manual">("generate");
|
||||||
|
const [state, formAction, pending] = useActionState(resetClientPassword, undefined);
|
||||||
|
|
||||||
|
if (state && "success" in state) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Passwort geändert</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Gib das neue Passwort an {client.name} weiter. Es wird nur jetzt angezeigt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<CopyField label="Email" value={client.email} />
|
||||||
|
<CopyField label="Neues Passwort" value={state.password} />
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" onClick={onDone}>Fertig</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Passwort zurücksetzen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Setzt das Passwort von <span className="font-medium text-foreground">{client.name}</span> neu.
|
||||||
|
Das alte Passwort funktioniert danach nicht mehr.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={formAction}>
|
||||||
|
<input type="hidden" name="profileId" value={client.id} />
|
||||||
|
<input type="hidden" name="mode" value={mode} />
|
||||||
|
<FieldGroup>
|
||||||
|
<div role="radiogroup" aria-label="Art des neuen Passworts" className="grid grid-cols-2 gap-2">
|
||||||
|
{(["generate", "manual"] as const).map((value) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm has-checked:border-primary has-checked:bg-surface"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mode-choice"
|
||||||
|
value={value}
|
||||||
|
checked={mode === value}
|
||||||
|
onChange={() => setMode(value)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
{value === "generate" ? "Automatisch generieren" : "Selbst festlegen"}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mode === "manual" && (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="new-password">Neues Passwort</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
name="password"
|
||||||
|
type="text"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={MIN_PASSWORD_LENGTH}
|
||||||
|
required
|
||||||
|
className="font-mono focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird geändert..." : "Passwort zurücksetzen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditClientContent({ client, onDone }: { client: Client; onDone: () => void }) {
|
||||||
|
async function run(prev: Parameters<typeof updateClientAccount>[0], formData: FormData) {
|
||||||
|
const result = await updateClientAccount(prev, formData);
|
||||||
|
if (result && "success" in result) onDone();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction, pending] = useActionState(run, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Client bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={formAction}>
|
||||||
|
<input type="hidden" name="profileId" value={client.id} />
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="edit-client-name">Name</FieldLabel>
|
||||||
|
<Input id="edit-client-name" name="name" defaultValue={client.name} required className="focus-visible:ring-0" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="edit-client-email">Email</FieldLabel>
|
||||||
|
<Input id="edit-client-email" name="email" type="email" defaultValue={client.email} required className="focus-visible:ring-0" />
|
||||||
|
<FieldDescription>Das ist auch die Login-Email des Clients.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird gespeichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientActionsMenu({ client }: { client: Client }) {
|
||||||
|
const [dialog, setDialog] = useState<"password" | "edit" | null>(null);
|
||||||
|
const [instanceKey, setInstanceKey] = useState(0);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setDialog(null);
|
||||||
|
setInstanceKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Aktionen für ${client.name}`} />}>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuItem onClick={() => setDialog("edit")}>
|
||||||
|
<PencilIcon /> Bearbeiten
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setDialog("password")}>
|
||||||
|
<KeyRoundIcon /> Passwort zurücksetzen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog open={dialog !== null} onOpenChange={(open) => !open && close()}>
|
||||||
|
<DialogContent>
|
||||||
|
{dialog === "password" && <ResetPasswordContent key={instanceKey} client={client} onDone={close} />}
|
||||||
|
{dialog === "edit" && <EditClientContent key={instanceKey} client={client} onDone={close} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||||
import { AddClientDialog } from "./add-client-dialog";
|
import { AddClientDialog } from "./add-client-dialog";
|
||||||
|
import { ClientActionsMenu } from "./client-actions-menu";
|
||||||
|
|
||||||
type Client = {
|
type Client = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -75,7 +76,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Client</TableHead>
|
<TableHead>Client</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-16" />
|
<TableHead className="w-24" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -102,15 +103,18 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
|
||||||
<Badge variant={status.variant}>{status.label}</Badge>
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<div className="flex justify-end gap-1">
|
||||||
variant="ghost"
|
<ClientActionsMenu client={client} />
|
||||||
size="icon-sm"
|
<Button
|
||||||
nativeButton={false}
|
variant="ghost"
|
||||||
render={<Link href={`/admin/clients/${client.id}`} />}
|
size="icon-sm"
|
||||||
aria-label={`${client.name} öffnen`}
|
nativeButton={false}
|
||||||
>
|
render={<Link href={`/admin/clients/${client.id}`} />}
|
||||||
<ArrowRightIcon />
|
aria-label={`${client.name} öffnen`}
|
||||||
</Button>
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
286
modules/admin/ui/components/settings-forms.tsx
Normal file
286
modules/admin/ui/components/settings-forms.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState, useTransition } from "react";
|
||||||
|
import { EyeIcon, EyeOffIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
changeOwnPassword,
|
||||||
|
deleteProductTemplate,
|
||||||
|
saveProductTemplate,
|
||||||
|
setProductTemplateActive,
|
||||||
|
updateOwnProfile,
|
||||||
|
type SettingsState,
|
||||||
|
} from "@/modules/admin/actions/settings";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||||
|
import type { AdminProductTemplate } from "@/modules/admin/queries/settings";
|
||||||
|
import { formatCurrency } from "@/modules/shared/lib/invoice-math";
|
||||||
|
|
||||||
|
function StateMessage({ state }: { state: SettingsState }) {
|
||||||
|
if (!state) return null;
|
||||||
|
if ("error" in state) return <p className="text-sm text-destructive">{state.error}</p>;
|
||||||
|
return state.message ? <p className="text-sm text-signal-success" aria-live="polite">{state.message}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileForm({ name, email }: { name: string; email: string }) {
|
||||||
|
const [state, formAction, pending] = useActionState(updateOwnProfile, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-name">Name</FieldLabel>
|
||||||
|
<Input id="settings-name" name="name" defaultValue={name} required className="focus-visible:ring-0" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-email">Email</FieldLabel>
|
||||||
|
<Input id="settings-email" value={email} disabled readOnly />
|
||||||
|
<FieldDescription>Die Login-Email kann nur in Supabase geändert werden.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<StateMessage state={state} />
|
||||||
|
</FieldGroup>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordInput({ id, name, label, autoComplete }: { id: string; name: string; label: string; autoComplete: string }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={visible ? "text" : "password"}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
required
|
||||||
|
className="pr-10 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="absolute top-1 right-1"
|
||||||
|
onClick={() => setVisible((value) => !value)}
|
||||||
|
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const [formKey, setFormKey] = useState(0);
|
||||||
|
|
||||||
|
async function run(prev: SettingsState, formData: FormData) {
|
||||||
|
const result = await changeOwnPassword(prev, formData);
|
||||||
|
if (result && "success" in result) setFormKey((key) => key + 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction, pending] = useActionState(run, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form key={formKey} action={formAction} className="flex flex-col gap-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<PasswordInput id="current-password" name="current" label="Aktuelles Passwort" autoComplete="current-password" />
|
||||||
|
<PasswordInput id="next-password" name="next" label="Neues Passwort" autoComplete="new-password" />
|
||||||
|
<PasswordInput id="confirm-password" name="confirm" label="Neues Passwort wiederholen" autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
|
||||||
|
<StateMessage state={state} />
|
||||||
|
</FieldGroup>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={pending}>{pending ? "Wird geändert..." : "Passwort ändern"}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateForm({ template, onDone }: { template?: AdminProductTemplate; onDone: () => void }) {
|
||||||
|
async function run(prev: SettingsState, formData: FormData) {
|
||||||
|
const result = await saveProductTemplate(prev, formData);
|
||||||
|
if (result && "success" in result) onDone();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction, pending] = useActionState(run, undefined);
|
||||||
|
const toInput = (value: number) => value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction}>
|
||||||
|
<input type="hidden" name="id" value={template?.id ?? ""} />
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="template-name">Bezeichnung <span className="text-destructive">*</span></FieldLabel>
|
||||||
|
<Input id="template-name" name="name" defaultValue={template?.name ?? ""} placeholder="z. B. Website Wartung (monatlich)" required />
|
||||||
|
<FieldDescription>Erscheint so als Position auf der Rechnung.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="template-description">Interne Beschreibung</FieldLabel>
|
||||||
|
<Input id="template-description" name="description" defaultValue={template?.description ?? ""} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="template-price">Preis netto (€) <span className="text-destructive">*</span></FieldLabel>
|
||||||
|
<Input id="template-price" name="unitPrice" inputMode="decimal" defaultValue={template ? toInput(template.unitPrice) : ""} placeholder="50,00" required />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="template-tax">MwSt (%)</FieldLabel>
|
||||||
|
<Input id="template-tax" name="taxRate" inputMode="decimal" defaultValue={template ? toInput(template.taxRate) : "0"} required />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<StateMessage state={state} />
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateDialog({ template }: { template?: AdminProductTemplate }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [instanceKey, setInstanceKey] = useState(0);
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) setInstanceKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
template ? (
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`${template.name} bearbeiten`} />
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{template ? <PencilIcon className="size-4" /> : <><PlusIcon className="size-4" /> Neue Vorlage</>}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>{template ? "Vorlage bearbeiten" : "Neue Vorlage"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<TemplateForm key={instanceKey} template={template} onDone={() => handleOpenChange(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteTemplateDialog({ template }: { template: AdminProductTemplate }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteProductTemplate(template.id);
|
||||||
|
if (result && "error" in result) setError(result.error);
|
||||||
|
else setOpen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`${template.name} löschen`} />}>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Vorlage löschen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span className="font-medium text-foreground">{template.name}</span> wird gelöscht. Bereits erstellte
|
||||||
|
Rechnungen bleiben unverändert. Zum Ausblenden reicht auch „Deaktivieren“.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||||
|
{pending ? "Wird gelöscht..." : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => startTransition(async () => { await setProductTemplateActive(template.id, !template.isActive); })}
|
||||||
|
>
|
||||||
|
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<TemplateDialog />
|
||||||
|
</div>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
|
||||||
|
Noch keine Vorlagen. Vorlagen lassen sich beim Erstellen einer Rechnung als Position auswählen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<li key={template.id} className={cn("flex flex-wrap items-center gap-3 px-4 py-3", !template.isActive && "opacity-60")}>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
{template.name}
|
||||||
|
{!template.isActive && <Badge>Inaktiv</Badge>}
|
||||||
|
</span>
|
||||||
|
{template.description && <span className="text-small text-muted-foreground">{template.description}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums">{formatCurrency(template.unitPrice)}</span>
|
||||||
|
<span className="w-20 text-right text-small text-muted-foreground tabular-nums">
|
||||||
|
{template.taxRate.toLocaleString("de-DE")} % MwSt
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ActiveToggle template={template} />
|
||||||
|
<TemplateDialog template={template} />
|
||||||
|
<DeleteTemplateDialog template={template} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
modules/admin/ui/views/dashboard-view.tsx
Normal file
202
modules/admin/ui/views/dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
ClipboardListIcon,
|
||||||
|
FileWarningIcon,
|
||||||
|
ReceiptIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
ServerCrashIcon,
|
||||||
|
UploadIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { getDashboardData, type AttentionItem } from "@/modules/admin/queries/dashboard"
|
||||||
|
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
|
||||||
|
import { berlinToday, formatDateTime, formatTaskTiming } from "@/modules/shared/lib/info-format"
|
||||||
|
import { TASK_STATUS_LABELS } from "@/modules/shared/schemas/info"
|
||||||
|
|
||||||
|
const ATTENTION_STYLES: Record<AttentionItem["kind"], { icon: LucideIcon; className: string }> = {
|
||||||
|
outage: { icon: ServerCrashIcon, className: "text-signal-error bg-signal-error/10" },
|
||||||
|
overdue: { icon: ReceiptIcon, className: "text-signal-error bg-signal-error/10" },
|
||||||
|
check: { icon: AlertTriangleIcon, className: "text-signal-warning bg-signal-warning/10" },
|
||||||
|
maintenance: { icon: WrenchIcon, className: "text-signal-warning bg-signal-warning/10" },
|
||||||
|
stammdaten: { icon: FileWarningIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
|
||||||
|
terms: { icon: ScaleIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
tone = "default",
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail?: string
|
||||||
|
tone?: "default" | "danger" | "success"
|
||||||
|
href?: string
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="text-small text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold tabular-nums",
|
||||||
|
tone === "danger" && "text-signal-error",
|
||||||
|
tone === "success" && "text-signal-success"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{detail && <span className="text-small text-muted-foreground">{detail}</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const className = "flex flex-col gap-1 rounded-lg border border-border p-4"
|
||||||
|
return href ? (
|
||||||
|
<Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link>
|
||||||
|
) : (
|
||||||
|
<div className={className}>{content}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 text-xl font-semibold">
|
||||||
|
{title}
|
||||||
|
{count !== undefined && count > 0 && <Badge>{count}</Badge>}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHint({ icon: Icon = CheckCircle2Icon, children }: { icon?: LucideIcon; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-2 rounded-lg border border-border bg-surface/50 p-4 text-muted-foreground">
|
||||||
|
<Icon className="size-4 shrink-0" /> {children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkRow({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link href={href} className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface">
|
||||||
|
{children}
|
||||||
|
<ArrowRightIcon className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardView = async ({ name }: { name: string }) => {
|
||||||
|
const data = await getDashboardData()
|
||||||
|
const today = berlinToday()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-semibold">Hallo {name.split(" ")[0]}</h1>
|
||||||
|
<p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} />
|
||||||
|
<StatCard
|
||||||
|
label="Davon überfällig"
|
||||||
|
value={formatCurrency(data.overdueTotal)}
|
||||||
|
detail={`${data.overdueCount} ${data.overdueCount === 1 ? "Rechnung" : "Rechnungen"}`}
|
||||||
|
tone={data.overdueCount > 0 ? "danger" : "default"}
|
||||||
|
/>
|
||||||
|
<StatCard label="Bezahlt diesen Monat" value={formatCurrency(data.paidThisMonthTotal)} tone={data.paidThisMonthTotal > 0 ? "success" : "default"} />
|
||||||
|
<StatCard
|
||||||
|
label="Clients"
|
||||||
|
value={String(data.clientCount)}
|
||||||
|
detail={data.clientsWithIssues > 0 ? `${data.clientsWithIssues} mit Störung` : "Keine Störungen"}
|
||||||
|
tone={data.clientsWithIssues > 0 ? "danger" : "default"}
|
||||||
|
href="/admin/clients"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="Braucht Aufmerksamkeit" count={data.attention.length}>
|
||||||
|
{data.attention.length === 0 ? (
|
||||||
|
<EmptyHint>Alles im grünen Bereich.</EmptyHint>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||||
|
{data.attention.map((item) => {
|
||||||
|
const style = ATTENTION_STYLES[item.kind]
|
||||||
|
return (
|
||||||
|
<LinkRow key={item.key} href={item.href}>
|
||||||
|
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", style.className)}>
|
||||||
|
<style.icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate font-medium">{item.title}</span>
|
||||||
|
{item.detail && <span className="truncate text-small text-muted-foreground">{item.detail}</span>}
|
||||||
|
</span>
|
||||||
|
</LinkRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
|
<Section title="Neueste Uploads von Clients">
|
||||||
|
{data.uploads.length === 0 ? (
|
||||||
|
<EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||||
|
{data.uploads.map((upload) => (
|
||||||
|
<LinkRow key={upload.key} href={upload.href}>
|
||||||
|
<UploadIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-medium">{upload.clientName}</span> hat {upload.count}{" "}
|
||||||
|
{upload.count === 1 ? "Datei" : "Dateien"} in „{upload.folderName}“ hochgeladen
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-small text-muted-foreground">
|
||||||
|
{upload.uploaderName} · zuletzt {formatDateTime(upload.latestAt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</LinkRow>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Laufende Vorgänge">
|
||||||
|
{data.tasks.length === 0 ? (
|
||||||
|
<EmptyHint icon={ClipboardListIcon}>Keine offenen Vorgänge.</EmptyHint>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||||
|
{data.tasks.map((task) => (
|
||||||
|
<LinkRow key={task.id} href={task.href}>
|
||||||
|
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20">
|
||||||
|
{TASK_STATUS_LABELS[task.status]}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate font-medium">{task.name}</span>
|
||||||
|
<span className="truncate text-small text-muted-foreground">{task.clientName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-small text-muted-foreground">
|
||||||
|
{formatTaskTiming(task.status, task.since, today)}
|
||||||
|
</span>
|
||||||
|
</LinkRow>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardView
|
||||||
38
modules/admin/ui/views/settings-view.tsx
Normal file
38
modules/admin/ui/views/settings-view.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { getAllProductTemplates } from "@/modules/admin/queries/settings"
|
||||||
|
import { PasswordForm, ProductTemplatesSection, ProfileForm } from "@/modules/admin/ui/components/settings-forms"
|
||||||
|
|
||||||
|
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-lg border border-border p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
|
<p className="text-small text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsView = async ({ name, email }: { name: string; email: string }) => {
|
||||||
|
const templates = await getAllProductTemplates()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 w-full max-w-4xl">
|
||||||
|
<h1 className="text-3xl font-semibold">Einstellungen</h1>
|
||||||
|
|
||||||
|
<Section title="Profil" description="So wirst du im Portal angezeigt, z. B. bei „zuletzt bearbeitet von“.">
|
||||||
|
<ProfileForm name={name} email={email} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Passwort" description="Ändere das Passwort für deinen Admin-Zugang.">
|
||||||
|
<PasswordForm />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Produktvorlagen" description="Vorlagen für Rechnungspositionen. Inaktive Vorlagen erscheinen nicht mehr im Rechnungsdialog.">
|
||||||
|
<ProductTemplatesSection templates={templates} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsView
|
||||||
225
modules/shared/actions/documents.ts
Normal file
225
modules/shared/actions/documents.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import * as z from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
import { getClientId } from "@/modules/shared/queries/profile";
|
||||||
|
import {
|
||||||
|
DOCUMENTS_BUCKET,
|
||||||
|
FolderNameSchema,
|
||||||
|
MAX_FILES_PER_UPLOAD,
|
||||||
|
UploadFileSchema,
|
||||||
|
} from "@/modules/shared/schemas/documents";
|
||||||
|
|
||||||
|
const IdSchema = z.string().uuid();
|
||||||
|
const URL_TTL_SECONDS = 5 * 60;
|
||||||
|
|
||||||
|
export type DocumentActionState = { error: string } | { success: true } | undefined;
|
||||||
|
|
||||||
|
type Access = { clientId: string; isAdmin: boolean };
|
||||||
|
|
||||||
|
// Admin: jeder Client. Client: nur das eigene Profil. Storage und Tabellen prüfen zusätzlich per RLS.
|
||||||
|
async function resolveAccess(profileId: string, adminOnly = false): Promise<Access | { error: string }> {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
|
||||||
|
if (adminOnly && !session.isAdmin) return { error: "Keine Berechtigung." };
|
||||||
|
if (!session.isAdmin && session.userId !== profileId) return { error: "Keine Berechtigung." };
|
||||||
|
|
||||||
|
const clientId = await getClientId(profileId);
|
||||||
|
if (!clientId) return { error: "Für diesen Client sind noch keine Stammdaten hinterlegt." };
|
||||||
|
|
||||||
|
return { clientId, isAdmin: session.isAdmin };
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateDocuments(profileId: string) {
|
||||||
|
revalidatePath("/client/storage");
|
||||||
|
revalidatePath(`/admin/clients/${profileId}/storage`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileName(name: string) {
|
||||||
|
const cleaned = name
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^\w.-]+/g, "_")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.slice(-120);
|
||||||
|
return cleaned === "" || cleaned === "." ? "datei" : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(_prev: DocumentActionState, formData: FormData): Promise<DocumentActionState> {
|
||||||
|
const profileId = String(formData.get("profileId") ?? "");
|
||||||
|
const access = await resolveAccess(profileId, true);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
|
||||||
|
const name = FolderNameSchema.safeParse(formData.get("name") ?? "");
|
||||||
|
if (!name.success) return { error: name.error.issues[0]?.message ?? "Ungültiger Name." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.from("folders").insert({ client_id: access.clientId, name: name.data });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { error: error.code === "23505" ? "Ein Ordner mit diesem Namen existiert bereits." : "Ordner konnte nicht erstellt werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateDocuments(profileId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(profileId: string, folderId: string): Promise<DocumentActionState> {
|
||||||
|
const access = await resolveAccess(profileId, true);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
if (!IdSchema.safeParse(folderId).success) return { error: "Ungültiger Ordner." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("folders")
|
||||||
|
.delete()
|
||||||
|
.eq("id", folderId)
|
||||||
|
.eq("client_id", access.clientId)
|
||||||
|
.select("id");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { error: error.code === "23503" ? "Der Ordner ist nicht leer. Bitte zuerst alle Dateien löschen." : "Ordner konnte nicht gelöscht werden." };
|
||||||
|
}
|
||||||
|
if (!data?.length) return { error: "Ordner nicht gefunden." };
|
||||||
|
|
||||||
|
revalidateDocuments(profileId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFolder(clientId: string, folderId: string) {
|
||||||
|
if (!IdSchema.safeParse(folderId).success) return false;
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase.from("folders").select("id").eq("id", folderId).eq("client_id", clientId).maybeSingle();
|
||||||
|
return Boolean(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreparedUpload = { documentId: string; path: string };
|
||||||
|
|
||||||
|
// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request).
|
||||||
|
export async function prepareUpload(
|
||||||
|
profileId: string,
|
||||||
|
folderId: string,
|
||||||
|
files: { name: string; size: number; type: string }[]
|
||||||
|
): Promise<{ error: string } | { uploads: PreparedUpload[] }> {
|
||||||
|
const access = await resolveAccess(profileId);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
|
||||||
|
if (files.length === 0 || files.length > MAX_FILES_PER_UPLOAD) {
|
||||||
|
return { error: `Bitte 1 bis ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` };
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
const parsed = UploadFileSchema.safeParse(file);
|
||||||
|
if (!parsed.success) return { error: `${file.name}: ${parsed.error.issues[0]?.message ?? "Ungültige Datei."}` };
|
||||||
|
}
|
||||||
|
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploads: files.map((file) => {
|
||||||
|
const documentId = crypto.randomUUID();
|
||||||
|
return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FinalizeSchema = z
|
||||||
|
.array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) }))
|
||||||
|
.min(1)
|
||||||
|
.max(MAX_FILES_PER_UPLOAD);
|
||||||
|
|
||||||
|
// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
|
||||||
|
export async function finalizeUpload(
|
||||||
|
profileId: string,
|
||||||
|
folderId: string,
|
||||||
|
uploads: { documentId: string; path: string; name: string; size: number; type: string }[]
|
||||||
|
): Promise<DocumentActionState> {
|
||||||
|
const access = await resolveAccess(profileId);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
|
||||||
|
const parsed = FinalizeSchema.safeParse(uploads);
|
||||||
|
if (!parsed.success) return { error: "Ungültige Upload-Daten." };
|
||||||
|
if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." };
|
||||||
|
|
||||||
|
const invalidPath = parsed.data.some(
|
||||||
|
(upload) => upload.path !== `${access.clientId}/${upload.documentId}/${safeFileName(upload.name)}`
|
||||||
|
);
|
||||||
|
if (invalidPath) return { error: "Ungültiger Speicherpfad." };
|
||||||
|
|
||||||
|
const session = await verifySession();
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.from("documents").insert(
|
||||||
|
parsed.data.map((upload) => ({
|
||||||
|
id: upload.documentId,
|
||||||
|
client_id: access.clientId,
|
||||||
|
folder_id: folderId,
|
||||||
|
name: upload.name,
|
||||||
|
storage_path: upload.path,
|
||||||
|
mime_type: upload.type || null,
|
||||||
|
size_bytes: upload.size,
|
||||||
|
uploaded_by: session.userId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
await supabase.storage.from(DOCUMENTS_BUCKET).remove(parsed.data.map((upload) => upload.path));
|
||||||
|
return { error: "Dateien konnten nicht gespeichert werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateDocuments(profileId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentUrls(
|
||||||
|
profileId: string,
|
||||||
|
documentIds: string[],
|
||||||
|
mode: "preview" | "download"
|
||||||
|
): Promise<{ error: string } | { files: { id: string; name: string; url: string }[] }> {
|
||||||
|
const access = await resolveAccess(profileId);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
|
||||||
|
const ids = z.array(z.string().uuid()).min(1).max(100).safeParse(documentIds);
|
||||||
|
if (!ids.success) return { error: "Ungültige Auswahl." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id, name, storage_path")
|
||||||
|
.eq("client_id", access.clientId)
|
||||||
|
.in("id", ids.data);
|
||||||
|
|
||||||
|
if (error || !data || data.length !== new Set(ids.data).size) return { error: "Nicht alle Dateien gefunden." };
|
||||||
|
|
||||||
|
const signed = await Promise.all(
|
||||||
|
data.map(async (row) => {
|
||||||
|
const { data: url } = await supabase.storage
|
||||||
|
.from(DOCUMENTS_BUCKET)
|
||||||
|
.createSignedUrl(row.storage_path, URL_TTL_SECONDS, mode === "download" ? { download: row.name } : undefined);
|
||||||
|
return url?.signedUrl ? { id: row.id as string, name: row.name as string, url: url.signedUrl } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signed.some((entry) => entry === null)) return { error: "Download-Link konnte nicht erstellt werden." };
|
||||||
|
return { files: signed as { id: string; name: string; url: string }[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocument(profileId: string, documentId: string): Promise<DocumentActionState> {
|
||||||
|
const access = await resolveAccess(profileId, true);
|
||||||
|
if ("error" in access) return access;
|
||||||
|
if (!IdSchema.safeParse(documentId).success) return { error: "Ungültige Datei." };
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.delete()
|
||||||
|
.eq("id", documentId)
|
||||||
|
.eq("client_id", access.clientId)
|
||||||
|
.select("storage_path");
|
||||||
|
|
||||||
|
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
||||||
|
|
||||||
|
await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]);
|
||||||
|
|
||||||
|
revalidateDocuments(profileId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
74
modules/shared/queries/documents.ts
Normal file
74
modules/shared/queries/documents.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import "server-only"
|
||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import { DOCUMENTS_BUCKET, canPreview, fileKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
||||||
|
|
||||||
|
const THUMBNAIL_TTL_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
export async function getFolders(clientId: string): Promise<Folder[]> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("folders")
|
||||||
|
.select("id, name, documents(count)")
|
||||||
|
.eq("client_id", clientId)
|
||||||
|
.is("parent_id", null)
|
||||||
|
.order("name")
|
||||||
|
|
||||||
|
if (error) throw new Error("Ordner konnten nicht geladen werden.")
|
||||||
|
|
||||||
|
return data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
documentCount: (row.documents as unknown as { count: number }[])[0]?.count ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFolder(clientId: string, folderId: string): Promise<Folder | null> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("folders")
|
||||||
|
.select("id, name")
|
||||||
|
.eq("client_id", clientId)
|
||||||
|
.eq("id", folderId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) throw new Error("Ordner konnte nicht geladen werden.")
|
||||||
|
return data ? { id: data.id, name: data.name, documentCount: 0 } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocuments(clientId: string, folderId: string): Promise<DocumentItem[]> {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id, name, mime_type, size_bytes, created_at, storage_path")
|
||||||
|
.eq("client_id", clientId)
|
||||||
|
.eq("folder_id", folderId)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
||||||
|
|
||||||
|
const imagePaths = data
|
||||||
|
.filter((row) => {
|
||||||
|
const kind = fileKind(row.name, row.mime_type)
|
||||||
|
return kind === "image" && canPreview(kind, row.name)
|
||||||
|
})
|
||||||
|
.map((row) => row.storage_path as string)
|
||||||
|
|
||||||
|
const thumbnails = new Map<string, string>()
|
||||||
|
if (imagePaths.length > 0) {
|
||||||
|
const { data: signed } = await supabase.storage
|
||||||
|
.from(DOCUMENTS_BUCKET)
|
||||||
|
.createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS)
|
||||||
|
for (const entry of signed ?? []) {
|
||||||
|
if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
mimeType: row.mime_type,
|
||||||
|
sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
thumbnailUrl: thumbnails.get(row.storage_path) ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
79
modules/shared/schemas/documents.ts
Normal file
79
modules/shared/schemas/documents.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
|
export const MAX_FILES_PER_UPLOAD = 20;
|
||||||
|
export const DOCUMENTS_BUCKET = "client-documents";
|
||||||
|
|
||||||
|
export type Folder = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
documentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | null;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileKind = "image" | "pdf" | "text" | "spreadsheet" | "presentation" | "document" | "archive" | "video" | "audio" | "other";
|
||||||
|
|
||||||
|
const EXTENSION_KINDS: Record<string, FileKind> = {
|
||||||
|
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image",
|
||||||
|
pdf: "pdf",
|
||||||
|
txt: "text", md: "text", csv: "spreadsheet", json: "text",
|
||||||
|
xls: "spreadsheet", xlsx: "spreadsheet", ods: "spreadsheet",
|
||||||
|
ppt: "presentation", pptx: "presentation", odp: "presentation",
|
||||||
|
doc: "document", docx: "document", odt: "document", rtf: "document",
|
||||||
|
zip: "archive", rar: "archive", "7z": "archive", gz: "archive",
|
||||||
|
mp4: "video", mov: "video", webm: "video",
|
||||||
|
mp3: "audio", wav: "audio", m4a: "audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fileKind(name: string, mimeType: string | null): FileKind {
|
||||||
|
const extension = name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (EXTENSION_KINDS[extension]) return EXTENSION_KINDS[extension];
|
||||||
|
if (mimeType?.startsWith("image/")) return "image";
|
||||||
|
if (mimeType?.startsWith("video/")) return "video";
|
||||||
|
if (mimeType?.startsWith("audio/")) return "audio";
|
||||||
|
if (mimeType === "application/pdf") return "pdf";
|
||||||
|
if (mimeType?.startsWith("text/")) return "text";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG wird bewusst nicht inline angezeigt (kann Skripte enthalten), nur heruntergeladen.
|
||||||
|
export function canPreview(kind: FileKind, name: string) {
|
||||||
|
if (kind === "image") return !name.toLowerCase().endsWith(".svg");
|
||||||
|
return kind === "pdf" || kind === "text" || kind === "video" || kind === "audio";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number | null) {
|
||||||
|
if (bytes === null) return "";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${value.toLocaleString("de-DE", { maximumFractionDigits: value < 10 ? 1 : 0 })} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadFileSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(255),
|
||||||
|
size: z.number().int().positive({ error: "Leere Dateien können nicht hochgeladen werden." }).max(MAX_FILE_BYTES, {
|
||||||
|
error: "Dateien dürfen maximal 50 MB groß sein.",
|
||||||
|
}),
|
||||||
|
type: z.string().max(255),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FolderNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Name ist erforderlich." })
|
||||||
|
.max(100, { error: "Name ist zu lang." })
|
||||||
|
.refine((value) => !/[\\/]/.test(value), { error: "Name darf keine Schrägstriche enthalten." });
|
||||||
459
modules/shared/ui/components/documents-browser.tsx
Normal file
459
modules/shared/ui/components/documents-browser.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { zipSync } from "fflate";
|
||||||
|
import { DownloadIcon, LoaderCircleIcon, Trash2Icon, UploadIcon } from "lucide-react";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { deleteDocument, finalizeUpload, getDocumentUrls, prepareUpload } from "@/modules/shared/actions/documents";
|
||||||
|
import {
|
||||||
|
DOCUMENTS_BUCKET,
|
||||||
|
MAX_FILES_PER_UPLOAD,
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
canPreview,
|
||||||
|
fileKind,
|
||||||
|
formatFileSize,
|
||||||
|
type DocumentItem,
|
||||||
|
} from "@/modules/shared/schemas/documents";
|
||||||
|
import { FileKindIcon } from "./file-icon";
|
||||||
|
|
||||||
|
const PARALLEL_UPLOADS = 3;
|
||||||
|
const TEXT_PREVIEW_LIMIT = 200_000;
|
||||||
|
|
||||||
|
function triggerDownload(href: string, fileName?: string) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = href;
|
||||||
|
if (fileName) link.download = fileName;
|
||||||
|
link.rel = "noopener";
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(name: string, used: Set<string>) {
|
||||||
|
if (!used.has(name)) {
|
||||||
|
used.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||||
|
const extension = dot > 0 ? name.slice(dot) : "";
|
||||||
|
let counter = 2;
|
||||||
|
while (used.has(`${base} (${counter})${extension}`)) counter++;
|
||||||
|
const next = `${base} (${counter})${extension}`;
|
||||||
|
used.add(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
|
||||||
|
let index = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (index < items.length) {
|
||||||
|
const item = items[index++];
|
||||||
|
await worker(item);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
|
||||||
|
const kind = fileKind(doc.name, doc.mimeType);
|
||||||
|
const [text, setText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== "text") return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((content) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setText(content.length > TEXT_PREVIEW_LIMIT ? `${content.slice(0, TEXT_PREVIEW_LIMIT)}\n…` : content);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => !cancelled && setText("Vorschau konnte nicht geladen werden."));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [kind, url]);
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
|
||||||
|
return <img src={url} alt={doc.name} className="mx-auto max-h-[70vh] w-auto rounded-md object-contain" />;
|
||||||
|
case "pdf":
|
||||||
|
return <iframe src={url} title={doc.name} className="h-[70vh] w-full rounded-md border border-border" />;
|
||||||
|
case "video":
|
||||||
|
return <video src={url} controls className="max-h-[70vh] w-full rounded-md" />;
|
||||||
|
case "audio":
|
||||||
|
return <audio src={url} controls className="w-full" />;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<pre className="max-h-[70vh] overflow-auto rounded-md border border-border bg-surface p-4 text-small whitespace-pre-wrap break-words">
|
||||||
|
{text ?? "Wird geladen..."}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewDialog({
|
||||||
|
doc,
|
||||||
|
profileId,
|
||||||
|
isAdmin,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
doc: DocumentItem | null;
|
||||||
|
profileId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDownload: (ids: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState<{ id: string; value: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doc) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getDocumentUrls(profileId, [doc.id], "preview").then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if ("error" in result) setError(result.error);
|
||||||
|
else setUrl({ id: doc.id, value: result.files[0].url });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, profileId]);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open) return;
|
||||||
|
setUrl(null);
|
||||||
|
setError(null);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!doc) return;
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
const result = await deleteDocument(profileId, doc.id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (result && "error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUrl = doc && url?.id === doc.id ? url.value : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={doc !== null} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[95vh] overflow-y-auto sm:max-w-4xl">
|
||||||
|
{doc && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="truncate pr-8">{doc.name}</DialogTitle>
|
||||||
|
<p className="text-small text-muted-foreground">{formatFileSize(doc.sizeBytes)}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
) : currentUrl ? (
|
||||||
|
<PreviewContent doc={doc} url={currentUrl} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-60 items-center justify-center text-muted-foreground">
|
||||||
|
<LoaderCircleIcon className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button type="button" variant={confirmDelete ? "destructive" : "outline"} onClick={handleDelete} disabled={deleting}>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
{deleting ? "Wird gelöscht..." : confirmDelete ? "Wirklich löschen?" : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" onClick={() => onDownload([doc.id])}>
|
||||||
|
<DownloadIcon className="size-4" /> Herunterladen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsBrowser({
|
||||||
|
profileId,
|
||||||
|
folderId,
|
||||||
|
isAdmin,
|
||||||
|
documents,
|
||||||
|
}: {
|
||||||
|
profileId: string;
|
||||||
|
folderId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
documents: DocumentItem[];
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: "error" | "info"; text: string } | null>(null);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<DocumentItem | null>(null);
|
||||||
|
|
||||||
|
const visibleSelection = documents.filter((doc) => selected.has(doc.id)).map((doc) => doc.id);
|
||||||
|
const busy = upload !== null || downloading;
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(fileList: FileList | File[]) {
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
if (files.length === 0 || busy) return;
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (files.length > MAX_FILES_PER_UPLOAD) {
|
||||||
|
setMessage({ type: "error", text: `Bitte höchstens ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tooLarge = files.find((file) => file.size > MAX_FILE_BYTES);
|
||||||
|
if (tooLarge) {
|
||||||
|
setMessage({ type: "error", text: `${tooLarge.name} ist größer als ${formatFileSize(MAX_FILE_BYTES)}.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared = await prepareUpload(
|
||||||
|
profileId,
|
||||||
|
folderId,
|
||||||
|
files.map((file) => ({ name: file.name, size: file.size, type: file.type }))
|
||||||
|
);
|
||||||
|
if ("error" in prepared) {
|
||||||
|
setMessage({ type: "error", text: prepared.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
|
||||||
|
const succeeded: typeof jobs = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
setUpload({ done: 0, total: jobs.length });
|
||||||
|
|
||||||
|
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from(DOCUMENTS_BUCKET)
|
||||||
|
.upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false });
|
||||||
|
if (error) failed.push(job.file.name);
|
||||||
|
else succeeded.push(job);
|
||||||
|
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (succeeded.length > 0) {
|
||||||
|
const result = await finalizeUpload(
|
||||||
|
profileId,
|
||||||
|
folderId,
|
||||||
|
succeeded.map(({ target, file }) => ({
|
||||||
|
documentId: target.documentId,
|
||||||
|
path: target.path,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpload(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
setMessage(
|
||||||
|
failed.length > 0
|
||||||
|
? { type: "error", text: `Nicht hochgeladen: ${failed.join(", ")}` }
|
||||||
|
: { type: "info", text: `${succeeded.length} ${succeeded.length === 1 ? "Datei" : "Dateien"} hochgeladen.` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(ids: string[]) {
|
||||||
|
if (ids.length === 0 || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
const result = await getDocumentUrls(profileId, ids, "download");
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
triggerDownload(result.files[0].url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getDocumentUrls(profileId, ids, "preview");
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
|
||||||
|
const used = new Set<string>();
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
for (const file of result.files) {
|
||||||
|
const response = await fetch(file.url);
|
||||||
|
if (!response.ok) throw new Error(`${file.name} konnte nicht geladen werden.`);
|
||||||
|
entries[uniqueName(file.name, used)] = new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobUrl = URL.createObjectURL(new Blob([zipSync(entries, { level: 0 })], { type: "application/zip" }));
|
||||||
|
triggerDownload(blobUrl, "Dokumente.zip");
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: error instanceof Error ? error.message : "Download fehlgeschlagen." });
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDocument(doc: DocumentItem) {
|
||||||
|
if (canPreview(fileKind(doc.name, doc.mimeType), doc.name)) setPreview(doc);
|
||||||
|
else handleDownload([doc.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex flex-col gap-4"
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!event.dataTransfer.types.includes("Files")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragActive(false);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
handleFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-small text-muted-foreground">
|
||||||
|
{documents.length > 0 && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 cursor-pointer accent-primary"
|
||||||
|
checked={visibleSelection.length === documents.length}
|
||||||
|
onChange={() =>
|
||||||
|
setSelected(visibleSelection.length === documents.length ? new Set() : new Set(documents.map((doc) => doc.id)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{visibleSelection.length > 0 ? `${visibleSelection.length} ausgewählt` : "Alle auswählen"}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => handleDownload(visibleSelection)} disabled={visibleSelection.length === 0 || busy}>
|
||||||
|
{downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||||
|
{downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => inputRef.current?.click()} disabled={busy}>
|
||||||
|
{upload ? <LoaderCircleIcon className="size-4 animate-spin" /> : <UploadIcon className="size-4" />}
|
||||||
|
{upload ? `Hochladen ${upload.done}/${upload.total}` : "Hochladen"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => event.target.files && handleFiles(event.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className={cn("text-sm", message.type === "error" ? "text-destructive" : "text-muted-foreground")} aria-live="polite">
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-surface/50 p-12 text-center text-muted-foreground transition-colors hover:bg-surface"
|
||||||
|
>
|
||||||
|
<UploadIcon className="size-8" />
|
||||||
|
<span>Noch keine Dateien. Hierher ziehen oder klicken zum Hochladen.</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{documents.map((doc) => {
|
||||||
|
const kind = fileKind(doc.name, doc.mimeType);
|
||||||
|
const isSelected = selected.has(doc.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={doc.id}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col overflow-hidden rounded-lg border bg-background transition-colors",
|
||||||
|
isSelected ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-ink-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggle(doc.id)}
|
||||||
|
aria-label={`${doc.name} auswählen`}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-2 left-2 z-10 size-4 cursor-pointer accent-primary transition-opacity",
|
||||||
|
isSelected ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDocument(doc)}
|
||||||
|
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
title={doc.name}
|
||||||
|
>
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-surface">
|
||||||
|
{doc.thumbnailUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
|
||||||
|
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<FileKindIcon kind={kind} className="size-12 stroke-[1.25]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 p-2.5">
|
||||||
|
<span className="truncate text-small font-medium">{doc.name}</span>
|
||||||
|
<span className="text-label text-muted-foreground">{formatFileSize(doc.sizeBytes)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-background/80 font-medium">
|
||||||
|
Dateien hier ablegen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PreviewDialog
|
||||||
|
doc={preview}
|
||||||
|
profileId={profileId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
modules/shared/ui/components/file-icon.tsx
Normal file
31
modules/shared/ui/components/file-icon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {
|
||||||
|
FileArchiveIcon,
|
||||||
|
FileAudioIcon,
|
||||||
|
FileIcon,
|
||||||
|
FileImageIcon,
|
||||||
|
FileSpreadsheetIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileVideoIcon,
|
||||||
|
PresentationIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import type { FileKind } from "@/modules/shared/schemas/documents";
|
||||||
|
|
||||||
|
const ICONS: Record<FileKind, { icon: LucideIcon; className: string }> = {
|
||||||
|
image: { icon: FileImageIcon, className: "text-violet-500" },
|
||||||
|
pdf: { icon: FileTextIcon, className: "text-red-500" },
|
||||||
|
text: { icon: FileTextIcon, className: "text-ink-500" },
|
||||||
|
document: { icon: FileTextIcon, className: "text-blue-500" },
|
||||||
|
spreadsheet: { icon: FileSpreadsheetIcon, className: "text-emerald-600" },
|
||||||
|
presentation: { icon: PresentationIcon, className: "text-orange-500" },
|
||||||
|
archive: { icon: FileArchiveIcon, className: "text-amber-600" },
|
||||||
|
video: { icon: FileVideoIcon, className: "text-pink-500" },
|
||||||
|
audio: { icon: FileAudioIcon, className: "text-cyan-600" },
|
||||||
|
other: { icon: FileIcon, className: "text-ink-500" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileKindIcon({ kind, className }: { kind: FileKind; className?: string }) {
|
||||||
|
const { icon: Icon, className: color } = ICONS[kind];
|
||||||
|
return <Icon className={cn(color, className)} aria-hidden />;
|
||||||
|
}
|
||||||
124
modules/shared/ui/components/folder-dialogs.tsx
Normal file
124
modules/shared/ui/components/folder-dialogs.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FolderPlusIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { createFolder, deleteFolder, type DocumentActionState } from "@/modules/shared/actions/documents";
|
||||||
|
|
||||||
|
function CreateFolderForm({ profileId, onDone }: { profileId: string; onDone: () => void }) {
|
||||||
|
async function run(prev: DocumentActionState, formData: FormData) {
|
||||||
|
const result = await createFolder(prev, formData);
|
||||||
|
if (result && "success" in result) onDone();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction, pending] = useActionState(run, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="profileId" value={profileId} />
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="folder-name">Name</FieldLabel>
|
||||||
|
<Input id="folder-name" name="name" placeholder="z. B. Verträge" autoFocus required />
|
||||||
|
</Field>
|
||||||
|
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird erstellt..." : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateFolderDialog({ profileId }: { profileId: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [instanceKey, setInstanceKey] = useState(0);
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) setInstanceKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger render={<Button />}>
|
||||||
|
<FolderPlusIcon className="size-4" /> Neuer Ordner
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="mb-2">
|
||||||
|
<DialogTitle>Neuer Ordner</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<CreateFolderForm key={instanceKey} profileId={profileId} onDone={() => handleOpenChange(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteFolderDialog({
|
||||||
|
profileId,
|
||||||
|
folderId,
|
||||||
|
folderName,
|
||||||
|
redirectTo,
|
||||||
|
}: {
|
||||||
|
profileId: string;
|
||||||
|
folderId: string;
|
||||||
|
folderName: string;
|
||||||
|
redirectTo: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await deleteFolder(profileId, folderId);
|
||||||
|
setPending(false);
|
||||||
|
if (result && "error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
router.push(redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button variant="outline" />}>
|
||||||
|
<Trash2Icon className="size-4" /> Ordner löschen
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>Ordner löschen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Möchtest du den Ordner <span className="font-medium text-foreground">{folderName}</span> löschen?
|
||||||
|
Das geht nur, wenn er leer ist.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||||
|
{pending ? "Wird gelöscht..." : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
modules/shared/ui/views/documents-panel.tsx
Normal file
96
modules/shared/ui/views/documents-panel.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { ChevronRightIcon, FolderIcon } from "lucide-react"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { getClientId } from "@/modules/shared/queries/profile"
|
||||||
|
import { getDocuments, getFolder, getFolders } from "@/modules/shared/queries/documents"
|
||||||
|
import { CreateFolderDialog, DeleteFolderDialog } from "@/modules/shared/ui/components/folder-dialogs"
|
||||||
|
import { DocumentsBrowser } from "@/modules/shared/ui/components/documents-browser"
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">{children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DocumentsPanel({
|
||||||
|
profileId,
|
||||||
|
isAdmin,
|
||||||
|
basePath,
|
||||||
|
folderId,
|
||||||
|
}: {
|
||||||
|
profileId: string
|
||||||
|
isAdmin: boolean
|
||||||
|
basePath: string
|
||||||
|
folderId?: string
|
||||||
|
}) {
|
||||||
|
const clientId = await getClientId(profileId)
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<h1 className="text-3xl font-semibold">Dokumente</h1>
|
||||||
|
<EmptyState>
|
||||||
|
{isAdmin
|
||||||
|
? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen."
|
||||||
|
: "Sobald eure Stammdaten hinterlegt sind, könnt ihr hier Dokumente ablegen."}
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
if (!z.string().uuid().safeParse(folderId).success) notFound()
|
||||||
|
const folder = await getFolder(clientId, folderId)
|
||||||
|
if (!folder) notFound()
|
||||||
|
const documents = await getDocuments(clientId, folder.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<nav aria-label="Pfad" className="flex min-w-0 items-center gap-1.5 text-3xl font-semibold">
|
||||||
|
<Link href={basePath} className="text-muted-foreground hover:text-foreground">Dokumente</Link>
|
||||||
|
<ChevronRightIcon className="size-6 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="truncate">{folder.name}</span>
|
||||||
|
</nav>
|
||||||
|
{isAdmin && documents.length === 0 && (
|
||||||
|
<DeleteFolderDialog profileId={profileId} folderId={folder.id} folderName={folder.name} redirectTo={basePath} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DocumentsBrowser profileId={profileId} folderId={folder.id} isAdmin={isAdmin} documents={documents} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = await getFolders(clientId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-3xl font-semibold">Dokumente</h1>
|
||||||
|
{isAdmin && <CreateFolderDialog profileId={profileId} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
{isAdmin ? "Noch keine Ordner. Lege einen Ordner an, damit Dateien hochgeladen werden können." : "Noch keine Ordner vorhanden."}
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<li key={folder.id}>
|
||||||
|
<Link
|
||||||
|
href={`${basePath}?folder=${folder.id}`}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-border bg-background p-5 text-center transition-colors hover:border-ink-300 hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring outline-none"
|
||||||
|
>
|
||||||
|
<FolderIcon className="size-12 fill-accent-400/20 stroke-[1.25] text-accent-500" aria-hidden />
|
||||||
|
<span className="w-full truncate font-medium">{folder.name}</span>
|
||||||
|
<span className="text-label text-muted-foreground">
|
||||||
|
{folder.documentCount} {folder.documentCount === 1 ? "Datei" : "Dateien"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue