feat: aprimora upload/anexos e regras de atendimento no portal

This commit is contained in:
Esdras Renan 2025-10-16 03:01:27 -03:00
parent 7e8023ed87
commit c90e99820f
8 changed files with 218 additions and 74 deletions

View file

@ -5,7 +5,7 @@ import { api } from "@/convex/_generated/api";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "@/components/ui/spinner";
import { Upload } from "lucide-react";
import { Upload, Check, X, AlertCircle } from "lucide-react";
import { Progress } from "@/components/ui/progress";
type Uploaded = { storageId: string; name: string; size?: number; type?: string; previewUrl?: string };
@ -26,7 +26,9 @@ export function Dropzone({
const generateUrl = useAction(api.files.generateUploadUrl);
const inputRef = useRef<HTMLInputElement>(null);
const [drag, setDrag] = useState(false);
const [items, setItems] = useState<Array<{ id: string; name: string; progress: number; status: "idle" | "uploading" | "done" | "error" }>>([]);
const [items, setItems] = useState<
Array<{ id: string; name: string; progress: number; status: "uploading" | "done" | "error" }>
>([]);
const startUpload = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).slice(0, maxFiles);
@ -55,29 +57,25 @@ export function Dropzone({
const res = JSON.parse(xhr.responseText);
if (res?.storageId) {
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 600);
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it))
);
} else {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, status: "error" } : it))
);
}
} catch {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, status: "error" } : it))
);
}
resolve();
};
xhr.onerror = () => {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, status: "error" } : it))
);
resolve();
};
xhr.send(file);
@ -130,15 +128,51 @@ export function Dropzone({
</div>
{items.length > 0 && (
<div className="space-y-2">
{items.map((it) => (
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
<span className="truncate">{it.name}</span>
<div className="flex min-w-[140px] items-center gap-2">
<Progress value={it.progress} className="h-1.5 w-24" />
<span className="w-10 text-right text-xs text-neutral-500">{it.progress}%</span>
{items.map((it) => {
const isUploading = it.status === "uploading";
const isDone = it.status === "done";
const isError = it.status === "error";
return (
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<span className="truncate">{it.name}</span>
{isError ? (
<span className="inline-flex items-center gap-1 text-xs text-rose-600">
<AlertCircle className="size-3.5" /> Falhou
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{isUploading ? (
<>
<Progress value={it.progress} className="h-1.5 w-24" />
<span className="w-10 text-right text-xs text-neutral-500">{it.progress}%</span>
</>
) : (
<>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
isDone ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700"
)}
>
{isDone ? <Check className="size-3.5" /> : <AlertCircle className="size-3.5" />}
{isDone ? "Pronto" : "Erro"}
</span>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
aria-label="Remover item"
onClick={() => setItems((prev) => prev.filter((item) => item.id !== it.id))}
>
<X className="size-3.5" />
</button>
</>
)}
</div>
</div>
</div>
))}
)
})}
</div>
)}
</div>