mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
see comments
This commit is contained in:
parent
4d42a172a5
commit
bc9b0b925e
10 changed files with 102 additions and 40 deletions
|
|
@ -6,6 +6,7 @@ import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { verifySession } from "@/lib/supabase/dal";
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
|
||||||
|
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
|
||||||
|
|
||||||
export type SettingsState = { error: string } | { success: true; message?: string } | undefined;
|
export type SettingsState = { error: string } | { success: true; message?: string } | undefined;
|
||||||
|
|
||||||
|
|
@ -31,9 +32,10 @@ export async function updateOwnProfile(_prev: SettingsState, formData: FormData)
|
||||||
const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId);
|
const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId);
|
||||||
if (error) return { error: "Name konnte nicht gespeichert werden." };
|
if (error) return { error: "Name konnte nicht gespeichert werden." };
|
||||||
|
|
||||||
await supabase.auth.updateUser({ data: { name: name.data } });
|
const { error: authError } = await supabase.auth.updateUser({ data: { name: name.data } });
|
||||||
|
|
||||||
revalidatePath("/admin", "layout");
|
revalidatePath("/admin", "layout");
|
||||||
|
if (authError) return { error: "Name konnte nicht vollständig gespeichert werden. Bitte erneut speichern." };
|
||||||
return { success: true, message: "Name gespeichert." };
|
return { success: true, message: "Name gespeichert." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +88,7 @@ const TemplateSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseDecimal(value: FormDataEntryValue | null) {
|
function parseDecimal(value: FormDataEntryValue | null) {
|
||||||
const trimmed = String(value ?? "").trim();
|
return parseDecimalInput(String(value ?? ""));
|
||||||
if (trimmed === "") return Number.NaN;
|
|
||||||
return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,9 @@ export async function getDashboardData(): Promise<DashboardData> {
|
||||||
.limit(MAX_TASKS),
|
.limit(MAX_TASKS),
|
||||||
supabase
|
supabase
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey(id, name, role)")
|
// !inner + Filter auf der Relation: nur Client-Uploads, bevor das Limit greift
|
||||||
|
.select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey!inner(id, name, role)")
|
||||||
|
.eq("uploader.role", "client")
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(RECENT_UPLOAD_ROWS),
|
.limit(RECENT_UPLOAD_ROWS),
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -233,17 +233,23 @@ function DeleteTemplateDialog({ template }: { template: AdminProductTemplate })
|
||||||
|
|
||||||
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
|
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setProductTemplateActive(template.id, !template.isActive);
|
||||||
|
if (result && "error" in result) setError(result.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<span className="flex items-center gap-2">
|
||||||
type="button"
|
{error && <span className="text-small text-destructive" role="alert">{error}</span>}
|
||||||
variant="ghost"
|
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={handleToggle}>
|
||||||
size="sm"
|
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||||
disabled={pending}
|
</Button>
|
||||||
onClick={() => startTransition(async () => { await setProductTemplateActive(template.id, !template.isActive); })}
|
</span>
|
||||||
>
|
|
||||||
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,18 @@ const FinalizeSchema = z
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(MAX_FILES_PER_UPLOAD);
|
.max(MAX_FILES_PER_UPLOAD);
|
||||||
|
|
||||||
|
// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer
|
||||||
|
// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen.
|
||||||
|
async function removeOrphanedObjects(paths: string[]) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths);
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
const referenced = new Set((existing ?? []).map((row) => row.storage_path as string));
|
||||||
|
const orphaned = paths.filter((path) => !referenced.has(path));
|
||||||
|
if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned);
|
||||||
|
}
|
||||||
|
|
||||||
// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
|
// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
|
||||||
export async function finalizeUpload(
|
export async function finalizeUpload(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
|
|
@ -148,21 +160,32 @@ export async function finalizeUpload(
|
||||||
|
|
||||||
const session = await verifySession();
|
const session = await verifySession();
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// Datei muss wirklich im Storage liegen; Größe und Typ kommen von dort, nicht aus dem Request.
|
||||||
|
const stored = await Promise.all(
|
||||||
|
parsed.data.map(async (upload) => {
|
||||||
|
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path);
|
||||||
|
return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const verified = stored.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||||
|
if (verified.length !== parsed.data.length) return { error: "Mindestens eine Datei wurde nicht vollständig hochgeladen." };
|
||||||
|
|
||||||
const { error } = await supabase.from("documents").insert(
|
const { error } = await supabase.from("documents").insert(
|
||||||
parsed.data.map((upload) => ({
|
verified.map((entry) => ({
|
||||||
id: upload.documentId,
|
id: entry.upload.documentId,
|
||||||
client_id: access.clientId,
|
client_id: access.clientId,
|
||||||
folder_id: folderId,
|
folder_id: folderId,
|
||||||
name: upload.name,
|
name: entry.upload.name,
|
||||||
storage_path: upload.path,
|
storage_path: entry.upload.path,
|
||||||
mime_type: upload.type || null,
|
mime_type: entry.contentType,
|
||||||
size_bytes: upload.size,
|
size_bytes: entry.size,
|
||||||
uploaded_by: session.userId,
|
uploaded_by: session.userId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
await supabase.storage.from(DOCUMENTS_BUCKET).remove(parsed.data.map((upload) => upload.path));
|
await removeOrphanedObjects(parsed.data.map((upload) => upload.path));
|
||||||
return { error: "Dateien konnten nicht gespeichert werden." };
|
return { error: "Dateien konnten nicht gespeichert werden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,8 +241,9 @@ export async function deleteDocument(profileId: string, documentId: string): Pro
|
||||||
|
|
||||||
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." };
|
||||||
|
|
||||||
await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]);
|
const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]);
|
||||||
|
|
||||||
revalidateDocuments(profileId);
|
revalidateDocuments(profileId);
|
||||||
|
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
modules/shared/lib/parse-decimal.ts
Normal file
11
modules/shared/lib/parse-decimal.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Deutsches Format mit Tausenderpunkten ("1.000", "1.234,50") oder schlicht ("12,5", "12.50").
|
||||||
|
// Mehrdeutiges wie "1.5,00" oder mehr als 2 Nachkommastellen ergibt NaN statt still falsch zu rechnen.
|
||||||
|
const GROUPED = /^\d{1,3}(\.\d{3})*(,\d{1,2})?$/;
|
||||||
|
const PLAIN = /^\d+([.,]\d{1,2})?$/;
|
||||||
|
|
||||||
|
export function parseDecimalInput(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (GROUPED.test(trimmed)) return Number(trimmed.replace(/\./g, "").replace(",", "."));
|
||||||
|
if (PLAIN.test(trimmed)) return Number(trimmed.replace(",", "."));
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import "server-only"
|
import "server-only"
|
||||||
import { createClient } from "@/lib/supabase/server"
|
import { createClient } from "@/lib/supabase/server"
|
||||||
import { DOCUMENTS_BUCKET, canPreview, fileKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
|
||||||
|
|
||||||
const THUMBNAIL_TTL_SECONDS = 60 * 60
|
const THUMBNAIL_TTL_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
|
@ -47,10 +47,7 @@ export async function getDocuments(clientId: string, folderId: string): Promise<
|
||||||
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
if (error) throw new Error("Dokumente konnten nicht geladen werden.")
|
||||||
|
|
||||||
const imagePaths = data
|
const imagePaths = data
|
||||||
.filter((row) => {
|
.filter((row) => previewKind(row.mime_type) === "image")
|
||||||
const kind = fileKind(row.name, row.mime_type)
|
|
||||||
return kind === "image" && canPreview(kind, row.name)
|
|
||||||
})
|
|
||||||
.map((row) => row.storage_path as string)
|
.map((row) => row.storage_path as string)
|
||||||
|
|
||||||
const thumbnails = new Map<string, string>()
|
const thumbnails = new Map<string, string>()
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,32 @@ export function fileKind(name: string, mimeType: string | null): FileKind {
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
// SVG wird bewusst nicht inline angezeigt (kann Skripte enthalten), nur heruntergeladen.
|
export type PreviewKind = "image" | "pdf" | "text" | "video" | "audio";
|
||||||
export function canPreview(kind: FileKind, name: string) {
|
|
||||||
if (kind === "image") return !name.toLowerCase().endsWith(".svg");
|
// Vorschau richtet sich nach dem Content-Type, mit dem der Storage die Datei ausliefert – nicht nach der Endung.
|
||||||
return kind === "pdf" || kind === "text" || kind === "video" || kind === "audio";
|
// So landet z. B. ein als ".pdf" benanntes HTML nie im Iframe. SVG (kann Skripte enthalten) nur als Download.
|
||||||
|
const PREVIEW_TYPES: Record<string, PreviewKind> = {
|
||||||
|
"image/png": "image",
|
||||||
|
"image/jpeg": "image",
|
||||||
|
"image/gif": "image",
|
||||||
|
"image/webp": "image",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"text/plain": "text",
|
||||||
|
"text/markdown": "text",
|
||||||
|
"text/csv": "text",
|
||||||
|
"application/json": "text",
|
||||||
|
"video/mp4": "video",
|
||||||
|
"video/webm": "video",
|
||||||
|
"video/quicktime": "video",
|
||||||
|
"audio/mpeg": "audio",
|
||||||
|
"audio/wav": "audio",
|
||||||
|
"audio/mp4": "audio",
|
||||||
|
"audio/x-m4a": "audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function previewKind(mimeType: string | null): PreviewKind | null {
|
||||||
|
if (!mimeType) return null;
|
||||||
|
return PREVIEW_TYPES[mimeType.split(";")[0].trim().toLowerCase()] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number | null) {
|
export function formatFileSize(bytes: number | null) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
||||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Ungültiges Datum." });
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Ungültiges Datum." });
|
||||||
|
|
||||||
// Grenzen und Nachkommastellen passend zu invoice_items: quantity numeric(10,2), unit_price numeric(12,2), tax_rate numeric(5,2)
|
// Grenzen und Nachkommastellen passend zu invoice_items: quantity numeric(10,2), unit_price numeric(12,2), tax_rate numeric(5,2)
|
||||||
const twoDecimals = (label: string) => z.number().multipleOf(0.01, { error: `${label}: maximal 2 Nachkommastellen.` });
|
const twoDecimals = (label: string) =>
|
||||||
|
z
|
||||||
|
.number({ error: `${label}: bitte eine gültige Zahl eingeben (z. B. 1.250,00).` })
|
||||||
|
.multipleOf(0.01, { error: `${label}: maximal 2 Nachkommastellen.` });
|
||||||
|
|
||||||
export const InvoiceItemSchema = z.object({
|
export const InvoiceItemSchema = z.object({
|
||||||
productTemplateId: z.string().uuid().nullable(),
|
productTemplateId: z.string().uuid().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
type ProductTemplate,
|
type ProductTemplate,
|
||||||
} from "@/modules/shared/schemas/invoice";
|
} from "@/modules/shared/schemas/invoice";
|
||||||
import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math";
|
import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math";
|
||||||
|
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
|
||||||
|
|
||||||
export type BillingPrefill = {
|
export type BillingPrefill = {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
@ -43,11 +44,7 @@ function emptyRow(): ItemRow {
|
||||||
return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" };
|
return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimal(value: string) {
|
const parseDecimal = parseDecimalInput;
|
||||||
const trimmed = value.trim();
|
|
||||||
const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed;
|
|
||||||
return normalized === "" ? Number.NaN : Number(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInputNumber(value: number) {
|
function formatInputNumber(value: number) {
|
||||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
|
return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import {
|
||||||
DOCUMENTS_BUCKET,
|
DOCUMENTS_BUCKET,
|
||||||
MAX_FILES_PER_UPLOAD,
|
MAX_FILES_PER_UPLOAD,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
canPreview,
|
|
||||||
fileKind,
|
fileKind,
|
||||||
|
previewKind,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
type DocumentItem,
|
type DocumentItem,
|
||||||
} from "@/modules/shared/schemas/documents";
|
} from "@/modules/shared/schemas/documents";
|
||||||
|
|
@ -58,7 +58,7 @@ async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Pro
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
|
function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
|
||||||
const kind = fileKind(doc.name, doc.mimeType);
|
const kind = previewKind(doc.mimeType);
|
||||||
const [text, setText] = useState<string | null>(null);
|
const [text, setText] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -321,7 +321,7 @@ export function DocumentsBrowser({
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDocument(doc: DocumentItem) {
|
function openDocument(doc: DocumentItem) {
|
||||||
if (canPreview(fileKind(doc.name, doc.mimeType), doc.name)) setPreview(doc);
|
if (previewKind(doc.mimeType)) setPreview(doc);
|
||||||
else handleDownload([doc.id]);
|
else handleDownload([doc.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue