feat(repairs): add repair documents
This commit is contained in:
parent
884a20e043
commit
6e7e75f864
21 changed files with 1222 additions and 6 deletions
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
documentId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id, documentId } = await params;
|
||||
return proxyHermesStreamRequest(
|
||||
request,
|
||||
`/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
documentId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyDocumentRequest(request: NextRequest, { params }: Params) {
|
||||
const { id, documentId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyDocumentRequest(request, context);
|
||||
}
|
||||
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/documents/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents`);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/documents/upload`);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr
|
|||
|
||||
import DetailSection from "@/components/common/DetailSection";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection";
|
||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||
|
|
@ -426,6 +427,10 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
})()}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Dokumente & Bilder">
|
||||
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
||||
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
||||
|
|
|
|||
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
454
frontend/athena/components/repairs/RepairDocumentsSection.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import type { RepairDocument, RepairDocumentType, RepairDocumentVisibility } from "@/types/repair";
|
||||
|
||||
const documentTypeLabels: Record<RepairDocumentType, string> = {
|
||||
device_photo: "Gerätefoto",
|
||||
fault_photo: "Fehlerbild",
|
||||
measurement: "Messbild",
|
||||
estimate: "Kostenvoranschlag",
|
||||
repair_report: "Reparaturbericht",
|
||||
shipping: "Versandbeleg",
|
||||
other: "Sonstiges",
|
||||
};
|
||||
|
||||
const documentTypes = Object.entries(documentTypeLabels) as Array<[RepairDocumentType, string]>;
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (typeof error === "object" && error !== null && "response" in error) {
|
||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
|
||||
}
|
||||
return "Aktion konnte nicht abgeschlossen werden";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatSize(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isImage(document: RepairDocument) {
|
||||
return document.mime_type.startsWith("image/");
|
||||
}
|
||||
|
||||
function isPdf(document: RepairDocument) {
|
||||
return document.mime_type === "application/pdf";
|
||||
}
|
||||
|
||||
function downloadUrl(repairId: number, documentId: number, disposition: "inline" | "attachment" = "inline") {
|
||||
return `/api/repairs/${repairId}/documents/${documentId}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
repairId: number;
|
||||
canUpdate: boolean;
|
||||
};
|
||||
|
||||
export default function RepairDocumentsSection({ repairId, canUpdate }: Props) {
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [documents, setDocuments] = useState<RepairDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [documentType, setDocumentType] = useState<RepairDocumentType>("device_photo");
|
||||
const [visibility, setVisibility] = useState<RepairDocumentVisibility>("internal");
|
||||
const [note, setNote] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RepairDocument | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [previewDocument, setPreviewDocument] = useState<RepairDocument | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await api.get<RepairDocument[]>(`/repairs/${repairId}/documents`);
|
||||
setDocuments(response.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repairId]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadDocuments();
|
||||
});
|
||||
}, [loadDocuments]);
|
||||
|
||||
const imageDocuments = useMemo(() => documents.filter(isImage), [documents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewDocument) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setPreviewDocument(null);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [previewDocument]);
|
||||
|
||||
function resetUploadForm() {
|
||||
setTitle("");
|
||||
setDocumentType("device_photo");
|
||||
setVisibility("internal");
|
||||
setNote("");
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(0);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadDocument() {
|
||||
if (!selectedFile) {
|
||||
showToast({ type: "error", title: "Keine Datei ausgewählt" });
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("title", title.trim() || selectedFile.name);
|
||||
formData.append("document_type", documentType);
|
||||
formData.append("visibility", visibility);
|
||||
formData.append("note", note.trim());
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
await api.post<RepairDocument>(`/repairs/${repairId}/documents/upload`, formData, {
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) {
|
||||
setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
},
|
||||
});
|
||||
await loadDocuments();
|
||||
resetUploadForm();
|
||||
setUploadOpen(false);
|
||||
showToast({ type: "success", title: "Dokument hochgeladen" });
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Upload fehlgeschlagen",
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDocument() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/repairs/${repairId}/documents/${deleteTarget.id}`);
|
||||
setDocuments((current) => current.filter((item) => item.id !== deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
showToast({ type: "success", title: "Dokument gelöscht" });
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Dokument konnte nicht gelöscht werden",
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openDocument(document: RepairDocument) {
|
||||
if (isImage(document)) {
|
||||
setPreviewDocument(document);
|
||||
setZoom(1);
|
||||
return;
|
||||
}
|
||||
window.open(downloadUrl(repairId, document.id, "inline"), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Dokumente werden geladen...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
{documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert
|
||||
</p>
|
||||
{canUpdate && (
|
||||
<Button type="button" onClick={() => setUploadOpen(true)}>
|
||||
<Plus />
|
||||
Dokument hochladen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
|
||||
<h3 className="text-lg font-semibold text-slate-950">Noch keine Dokumente oder Bilder vorhanden.</h3>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">
|
||||
Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert.
|
||||
</p>
|
||||
{canUpdate && (
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button type="button" onClick={() => setUploadOpen(true)}><Upload />Upload starten</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{documents.map((document) => (
|
||||
<article key={document.id} className="overflow-hidden rounded-lg border bg-white">
|
||||
<button
|
||||
type="button"
|
||||
className="flex aspect-video w-full items-center justify-center bg-slate-100 text-slate-500"
|
||||
onClick={() => openDocument(document)}
|
||||
aria-label={`${document.title} öffnen`}
|
||||
>
|
||||
{isImage(document) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={downloadUrl(repairId, document.id)} alt={document.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="h-10 w-10" />
|
||||
<span className="text-xs font-medium">{isPdf(document) ? "PDF" : document.mime_type}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="flex items-start gap-2">
|
||||
{isImage(document) ? <ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" /> : <FileText className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />}
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-semibold text-slate-950">{document.title}</h3>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{document.original_filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
{document.note && <p className="mt-3 line-clamp-2 text-sm text-slate-600">{document.note}</p>}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs">
|
||||
<Meta label="Typ" value={documentTypeLabels[document.document_type]} />
|
||||
<Meta label="Größe" value={formatSize(document.size_bytes)} />
|
||||
<Meta label="MIME" value={document.mime_type} />
|
||||
<Meta label="Sichtbarkeit" value={document.visibility === "customer" ? "Kunde" : "Intern"} />
|
||||
<Meta label="Upload" value={formatDate(document.created_at)} />
|
||||
<Meta label="Von" value={document.uploaded_by_display_name || document.uploaded_by_username || "System"} />
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openDocument(document)}>
|
||||
<Eye />
|
||||
Vorschau
|
||||
</Button>
|
||||
<a href={downloadUrl(repairId, document.id, "attachment")} className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<Download />
|
||||
Download
|
||||
</a>
|
||||
{canUpdate && (
|
||||
<Button type="button" size="sm" variant="destructive" onClick={() => setDeleteTarget(document)}>
|
||||
<Trash2 />
|
||||
Löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={uploadOpen} onOpenChange={(open) => {
|
||||
setUploadOpen(open);
|
||||
if (!open && !uploading) resetUploadForm();
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dokument hochladen</DialogTitle>
|
||||
<DialogDescription>JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Field label="Datei">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedFile(file);
|
||||
if (file && !title) setTitle(file.name);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Titel">
|
||||
<Input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Dokumenttyp">
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={documentType}
|
||||
onChange={(event) => setDocumentType(event.target.value as RepairDocumentType)}
|
||||
>
|
||||
{documentTypes.map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Sichtbarkeit">
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.target.value as RepairDocumentVisibility)}
|
||||
>
|
||||
<option value="internal">Intern</option>
|
||||
<option value="customer">Kunde vorbereitet</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Notiz">
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{uploading && (
|
||||
<div className="rounded-lg border bg-slate-50 p-3">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full bg-slate-900 transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadProgress}% hochgeladen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setUploadOpen(false)} disabled={uploading}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void uploadDocument()} disabled={uploading || !selectedFile}>
|
||||
<Upload />
|
||||
{uploading ? "Lädt hoch..." : "Upload"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Dokument löschen?"
|
||||
description="Die Datei wird aus dem Storage entfernt und kann nicht wiederhergestellt werden."
|
||||
pending={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleting) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={() => void deleteDocument()}
|
||||
>
|
||||
{deleteTarget && (
|
||||
<p className="text-sm text-slate-600">
|
||||
{deleteTarget.title}
|
||||
<br />
|
||||
<span className="text-slate-500">{deleteTarget.original_filename}</span>
|
||||
</p>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
{previewDocument && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog" aria-modal="true">
|
||||
<div className="absolute right-4 top-4 flex gap-2">
|
||||
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.max(0.5, current - 0.25))} aria-label="Verkleinern">
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="icon" onClick={() => setZoom((current) => Math.min(3, current + 0.25))} aria-label="Vergrößern">
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="icon" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen">
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" className="absolute inset-0 cursor-default" onClick={() => setPreviewDocument(null)} aria-label="Vorschau schließen" />
|
||||
<div className="relative max-h-[90vh] max-w-[90vw] overflow-auto">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={downloadUrl(repairId, previewDocument.id)}
|
||||
alt={previewDocument.title}
|
||||
className="relative block max-h-[85vh] max-w-[85vw] object-contain transition-transform"
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="absolute bottom-4 left-1/2 max-w-[80vw] -translate-x-1/2 truncate rounded bg-black/60 px-3 py-2 text-sm text-white">
|
||||
{previewDocument.title} · {imageDocuments.findIndex((item) => item.id === previewDocument.id) + 1}/{imageDocuments.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="font-medium uppercase tracking-wide text-slate-500">{label}</dt>
|
||||
<dd className="mt-1 break-words text-slate-950">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,15 @@ export type RepairStatus =
|
|||
|
||||
export type RepairPriority = "low" | "normal" | "high" | "urgent";
|
||||
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
|
||||
export type RepairDocumentType =
|
||||
| "device_photo"
|
||||
| "fault_photo"
|
||||
| "measurement"
|
||||
| "estimate"
|
||||
| "repair_report"
|
||||
| "shipping"
|
||||
| "other";
|
||||
export type RepairDocumentVisibility = "internal" | "customer";
|
||||
|
||||
export interface Repair {
|
||||
id: number;
|
||||
|
|
@ -132,3 +141,23 @@ export interface RepairNotificationOverview {
|
|||
templates: RepairNotificationTemplate[];
|
||||
events: RepairNotificationEvent[];
|
||||
}
|
||||
|
||||
export interface RepairDocument {
|
||||
id: number;
|
||||
repair_id: number;
|
||||
title: string;
|
||||
document_type: RepairDocumentType;
|
||||
original_filename: string;
|
||||
stored_filename: string;
|
||||
storage_path: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
checksum_sha256: string;
|
||||
visibility: RepairDocumentVisibility;
|
||||
note: string | null;
|
||||
uploaded_by_user_id: number | null;
|
||||
uploaded_by_username: string;
|
||||
uploaded_by_display_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue