mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +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 { verifySession } from "@/lib/supabase/dal";
|
||||
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;
|
||||
|
||||
|
|
@ -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);
|
||||
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");
|
||||
if (authError) return { error: "Name konnte nicht vollständig gespeichert werden. Bitte erneut speichern." };
|
||||
return { success: true, message: "Name gespeichert." };
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +88,7 @@ const TemplateSchema = z.object({
|
|||
});
|
||||
|
||||
function parseDecimal(value: FormDataEntryValue | null) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (trimmed === "") return Number.NaN;
|
||||
return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed);
|
||||
return parseDecimalInput(String(value ?? ""));
|
||||
}
|
||||
|
||||
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ export async function getDashboardData(): Promise<DashboardData> {
|
|||
.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)")
|
||||
// !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 })
|
||||
.limit(RECENT_UPLOAD_ROWS),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -233,17 +233,23 @@ function DeleteTemplateDialog({ template }: { template: AdminProductTemplate })
|
|||
|
||||
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
|
||||
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 (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => startTransition(async () => { await setProductTemplateActive(template.id, !template.isActive); })}
|
||||
>
|
||||
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
<span className="flex items-center gap-2">
|
||||
{error && <span className="text-small text-destructive" role="alert">{error}</span>}
|
||||
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={handleToggle}>
|
||||
{template.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,18 @@ const FinalizeSchema = z
|
|||
.min(1)
|
||||
.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.
|
||||
export async function finalizeUpload(
|
||||
profileId: string,
|
||||
|
|
@ -148,21 +160,32 @@ export async function finalizeUpload(
|
|||
|
||||
const session = await verifySession();
|
||||
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(
|
||||
parsed.data.map((upload) => ({
|
||||
id: upload.documentId,
|
||||
verified.map((entry) => ({
|
||||
id: entry.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,
|
||||
name: entry.upload.name,
|
||||
storage_path: entry.upload.path,
|
||||
mime_type: entry.contentType,
|
||||
size_bytes: entry.size,
|
||||
uploaded_by: session.userId,
|
||||
}))
|
||||
);
|
||||
|
||||
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." };
|
||||
}
|
||||
|
||||
|
|
@ -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." };
|
||||
|
||||
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);
|
||||
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
|
||||
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 { 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
|
||||
|
||||
|
|
@ -47,10 +47,7 @@ export async function getDocuments(clientId: string, folderId: string): Promise<
|
|||
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)
|
||||
})
|
||||
.filter((row) => previewKind(row.mime_type) === "image")
|
||||
.map((row) => row.storage_path as string)
|
||||
|
||||
const thumbnails = new Map<string, string>()
|
||||
|
|
|
|||
|
|
@ -44,10 +44,32 @@ export function fileKind(name: string, mimeType: string | null): FileKind {
|
|||
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 type PreviewKind = "image" | "pdf" | "text" | "video" | "audio";
|
||||
|
||||
// Vorschau richtet sich nach dem Content-Type, mit dem der Storage die Datei ausliefert – nicht nach der Endung.
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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." });
|
||||
|
||||
// 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({
|
||||
productTemplateId: z.string().uuid().nullable(),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type ProductTemplate,
|
||||
} from "@/modules/shared/schemas/invoice";
|
||||
import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math";
|
||||
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
|
||||
|
||||
export type BillingPrefill = {
|
||||
companyName: string;
|
||||
|
|
@ -43,11 +44,7 @@ function emptyRow(): ItemRow {
|
|||
return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" };
|
||||
}
|
||||
|
||||
function parseDecimal(value: string) {
|
||||
const trimmed = value.trim();
|
||||
const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed;
|
||||
return normalized === "" ? Number.NaN : Number(normalized);
|
||||
}
|
||||
const parseDecimal = parseDecimalInput;
|
||||
|
||||
function formatInputNumber(value: number) {
|
||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {
|
|||
DOCUMENTS_BUCKET,
|
||||
MAX_FILES_PER_UPLOAD,
|
||||
MAX_FILE_BYTES,
|
||||
canPreview,
|
||||
fileKind,
|
||||
previewKind,
|
||||
formatFileSize,
|
||||
type DocumentItem,
|
||||
} 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 }) {
|
||||
const kind = fileKind(doc.name, doc.mimeType);
|
||||
const kind = previewKind(doc.mimeType);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -321,7 +321,7 @@ export function DocumentsBrowser({
|
|||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue