mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
feat(work-orders): accept more than one uplift evidence file
Stage every selected file, wait for each scan, and download by document id. Refresh the work-order detail snapshot for the current tab strip.
This commit is contained in:
parent
52934dc959
commit
64686a59eb
13 changed files with 232 additions and 39 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 156 KiB |
|
|
@ -6,8 +6,8 @@ import { Text } from "@/components/ui/text";
|
|||
const ACCEPTED_TYPES = new Set(["application/pdf", "image/jpeg", "image/jpg", "image/png"]);
|
||||
|
||||
type UpliftAttachmentPickerProps = {
|
||||
file: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
files: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
};
|
||||
|
||||
function isAllowedUpliftEvidenceFile(file: File): boolean {
|
||||
|
|
@ -15,9 +15,9 @@ function isAllowedUpliftEvidenceFile(file: File): boolean {
|
|||
return /\.(pdf|jpe?g|png)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function firstAllowedFile(incoming: FileList | null): File | null {
|
||||
if (!incoming || incoming.length === 0) return null;
|
||||
return Array.from(incoming).find(isAllowedUpliftEvidenceFile) ?? null;
|
||||
function allowedFiles(incoming: FileList | null): File[] {
|
||||
if (!incoming || incoming.length === 0) return [];
|
||||
return Array.from(incoming).filter(isAllowedUpliftEvidenceFile);
|
||||
}
|
||||
|
||||
function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
|
|
@ -46,19 +46,20 @@ function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void
|
|||
);
|
||||
}
|
||||
|
||||
export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPickerProps) {
|
||||
export function UpliftAttachmentPicker({ files, onChange }: UpliftAttachmentPickerProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const stageFile = (incoming: FileList | null) => {
|
||||
const next = firstAllowedFile(incoming);
|
||||
if (next) onChange(next);
|
||||
const stageFiles = (incoming: FileList | null) => {
|
||||
const next = allowedFiles(incoming);
|
||||
if (next.length === 0) return;
|
||||
onChange([...files, ...next]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<FieldLabel>Attachment</FieldLabel>
|
||||
<Text as="p" variant="caption" tone="muted" className="mb-2">
|
||||
Optional — PDF, JPG, or PNG.
|
||||
Optional — one or more PDF, JPG, or PNG files.
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -66,7 +67,7 @@ export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPicke
|
|||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
stageFile(event.dataTransfer.files);
|
||||
stageFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full items-center justify-center gap-2 transition-colors hover:bg-(--color-bg-muted)"
|
||||
style={{
|
||||
|
|
@ -77,22 +78,29 @@ export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPicke
|
|||
>
|
||||
<Upload size={14} aria-hidden style={{ color: "var(--color-text-muted)" }} />
|
||||
<Text as="span" variant="caption" tone="muted">
|
||||
Drag a file here or click to browse
|
||||
Drag files here or click to browse
|
||||
</Text>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
stageFile(event.target.files);
|
||||
stageFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{file != null && (
|
||||
<div className="mt-2">
|
||||
<UpliftStagedFile file={file} onRemove={() => onChange(null)} />
|
||||
{files.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{files.map((file, index) => (
|
||||
<UpliftStagedFile
|
||||
key={`${file.name}-${file.lastModified}-${index}`}
|
||||
file={file}
|
||||
onRemove={() => onChange(files.filter((_, itemIndex) => itemIndex !== index))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,7 @@ import {
|
|||
type WorkOrderUpliftCreateFormProps = {
|
||||
pending?: boolean;
|
||||
remainingAllowance: number;
|
||||
onSubmit: (input: {
|
||||
amount: number;
|
||||
notes: string;
|
||||
file: File | null;
|
||||
}) => void | Promise<unknown>;
|
||||
onSubmit: (input: { amount: number; notes: string; files: File[] }) => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
function canSubmitUplift(amount: number, notes: string): boolean {
|
||||
|
|
@ -64,17 +60,17 @@ export function WorkOrderUpliftCreateForm({
|
|||
}: WorkOrderUpliftCreateFormProps) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const parsedAmount = Number(amount);
|
||||
const helper = upliftAllowanceHelper(parsedAmount, remainingAllowance);
|
||||
const canSubmit = canSubmitUplift(parsedAmount, notes);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await onSubmit({ amount: parsedAmount, notes: notes.trim(), file });
|
||||
await onSubmit({ amount: parsedAmount, notes: notes.trim(), files });
|
||||
setAmount("");
|
||||
setNotes("");
|
||||
setFile(null);
|
||||
setFiles([]);
|
||||
} catch {
|
||||
// why: the mutation toasts onError, so a failed submit keeps the typed values.
|
||||
}
|
||||
|
|
@ -113,7 +109,7 @@ export function WorkOrderUpliftCreateForm({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<UpliftAttachmentPicker file={file} onChange={setFile} />
|
||||
<UpliftAttachmentPicker files={files} onChange={setFiles} />
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ function UpliftFiledAttachment({
|
|||
}) {
|
||||
const hasUrl = file.url !== "";
|
||||
const downloadEvidence = () => {
|
||||
void upliftsApi.downloadEvidence(upliftId, file.name).catch((error: unknown) => {
|
||||
const documentId = /^\d+$/.test(file.id) ? file.id : undefined;
|
||||
void upliftsApi.downloadEvidence(upliftId, file.name, documentId).catch((error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unable to download evidence.");
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -105,8 +105,9 @@ export const upliftsApi = {
|
|||
downloadEvidence: async (
|
||||
id: string | number,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
documentId?: string | number,
|
||||
): Promise<void> => {
|
||||
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
|
||||
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence", documentId);
|
||||
const blob = await response.blob();
|
||||
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||
},
|
||||
|
|
@ -120,8 +121,9 @@ export const upliftsApi = {
|
|||
id: string | number,
|
||||
tab: Window | null,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
documentId?: string | number,
|
||||
): Promise<void> => {
|
||||
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
|
||||
const response = await fetchEvidence(id, "upliftsApi.openEvidence", documentId);
|
||||
const blob = await response.blob();
|
||||
const type = blob.type.split(";")[0].trim().toLowerCase();
|
||||
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
|
||||
|
|
@ -145,9 +147,14 @@ const INLINE_EVIDENCE_TYPES = new Set([
|
|||
|
||||
const EVIDENCE_URL_TTL_MS = 60_000;
|
||||
|
||||
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
|
||||
async function fetchEvidence(
|
||||
id: string | number,
|
||||
operation: string,
|
||||
documentId?: string | number,
|
||||
): Promise<Response> {
|
||||
const options = documentId == null ? undefined : { searchParams: { documentId } };
|
||||
try {
|
||||
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
|
||||
return await apiRequestRaw("get", evidenceUrl(id), options, operation);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
|
|
|
|||
|
|
@ -46,13 +46,21 @@ export const workOrderUpliftsApi = {
|
|||
workOrderId: string | number,
|
||||
input: CreateWorkOrderUpliftInput,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const body: { amount: number; notes: string; evidenceDocumentId?: number } = {
|
||||
const body: {
|
||||
amount: number;
|
||||
notes: string;
|
||||
evidenceDocumentId?: number;
|
||||
evidenceDocumentIds?: number[];
|
||||
} = {
|
||||
amount: input.amount,
|
||||
notes: input.notes,
|
||||
};
|
||||
if (input.evidenceDocumentId != null) {
|
||||
body.evidenceDocumentId = input.evidenceDocumentId;
|
||||
}
|
||||
if (input.evidenceDocumentIds != null && input.evidenceDocumentIds.length > 0) {
|
||||
body.evidenceDocumentIds = input.evidenceDocumentIds;
|
||||
}
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), body);
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ export interface WorkOrderUplift {
|
|||
export interface CreateWorkOrderUpliftInput {
|
||||
amount: number;
|
||||
notes: string;
|
||||
file?: File | null;
|
||||
files?: File[];
|
||||
evidenceDocumentId?: number;
|
||||
evidenceDocumentIds?: number[];
|
||||
}
|
||||
|
||||
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type CreateUpliftBody = {
|
|||
amount: number;
|
||||
notes: string;
|
||||
evidenceDocumentId?: number;
|
||||
evidenceDocumentIds?: number[];
|
||||
};
|
||||
|
||||
export type SubmitWorkOrderUpliftDeps = {
|
||||
|
|
@ -62,12 +63,15 @@ export async function submitWorkOrderUplift(
|
|||
input: CreateWorkOrderUpliftInput,
|
||||
deps: SubmitWorkOrderUpliftDeps,
|
||||
): Promise<WorkOrderUplift> {
|
||||
const evidenceDocumentId = input.file
|
||||
? await waitForPassedScan(workOrderId, input.file, deps)
|
||||
: undefined;
|
||||
const evidenceDocumentIds: number[] = [];
|
||||
for (const file of input.files ?? []) {
|
||||
evidenceDocumentIds.push(await waitForPassedScan(workOrderId, file, deps));
|
||||
}
|
||||
const [evidenceDocumentId] = evidenceDocumentIds;
|
||||
return deps.create(workOrderId, {
|
||||
amount: input.amount,
|
||||
notes: input.notes,
|
||||
evidenceDocumentId,
|
||||
evidenceDocumentIds: evidenceDocumentIds.length > 0 ? evidenceDocumentIds : undefined,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from "react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function PickerHarness({ onChange }: { onChange: (files: File[]) => void }) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
return (
|
||||
<UpliftAttachmentPicker
|
||||
files={files}
|
||||
onChange={(next) => {
|
||||
setFiles(next);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function selectFiles(files: File[]) {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
fireEvent.change(input as HTMLInputElement, { target: { files } });
|
||||
}
|
||||
|
||||
describe("UpliftAttachmentPicker", () => {
|
||||
it("keeps every allowed file from one multi-select", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<PickerHarness onChange={onChange} />);
|
||||
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
|
||||
const notes = new File(["text"], "notes.txt", { type: "text/plain" });
|
||||
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
selectFiles([quote, notes, photo]);
|
||||
|
||||
expect(screen.getByText("quote.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("photo.jpg")).toBeInTheDocument();
|
||||
expect(screen.queryByText("notes.txt")).not.toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith([quote, photo]);
|
||||
expect(document.querySelector('input[type="file"]')?.hasAttribute("multiple")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends a later selection instead of replacing the staged files", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<PickerHarness onChange={onChange} />);
|
||||
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
|
||||
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
selectFiles([quote]);
|
||||
selectFiles([photo]);
|
||||
|
||||
expect(screen.getByText("quote.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("photo.jpg")).toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenLastCalledWith([quote, photo]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
|
||||
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const downloadEvidence = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
|
||||
upliftsApi: { downloadEvidence },
|
||||
}));
|
||||
|
||||
const uplift: WorkOrderUplift = {
|
||||
id: 9,
|
||||
status: "auto_approved",
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
requestedAt: "2026-07-20T10:00:00Z",
|
||||
requestedById: "dispatcher-1",
|
||||
requestedByName: "Dispatcher",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
attachments: [
|
||||
{ id: "22", name: "quote.pdf", url: "" },
|
||||
{ id: "23", name: "photo.jpg", url: "" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("WorkOrderUpliftListItem evidence download", () => {
|
||||
it("downloads each attachment by its document id", () => {
|
||||
renderWithProviders(
|
||||
<WorkOrderUpliftListItem
|
||||
uplift={uplift}
|
||||
readOnly={false}
|
||||
currentUserId="dispatcher-1"
|
||||
currentUserIsAdmin={false}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "photo.jpg" }));
|
||||
|
||||
expect(downloadEvidence).toHaveBeenCalledWith(9, "photo.jpg", "23");
|
||||
});
|
||||
});
|
||||
|
|
@ -43,6 +43,27 @@ describe("upliftsApi", () => {
|
|||
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("requests one linked document when a document id is provided", async () => {
|
||||
apiRequestRaw.mockResolvedValueOnce(
|
||||
new Response("%PDF", {
|
||||
headers: { "Content-Disposition": 'attachment; filename="photo.jpg"' },
|
||||
}),
|
||||
);
|
||||
URL.createObjectURL = vi.fn(() => "blob:evidence");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await upliftsApi.downloadEvidence(7, "photo.jpg", 23);
|
||||
|
||||
expect(apiRequestRaw).toHaveBeenCalledWith(
|
||||
"get",
|
||||
"uplifts/7/evidence",
|
||||
{ searchParams: { documentId: 23 } },
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
click.mockRestore();
|
||||
});
|
||||
|
||||
describe("openEvidence", () => {
|
||||
function evidenceResponse(body: string, contentType: string): Response {
|
||||
return new Response(body, {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,24 @@ describe("workOrderUpliftsApi", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("includes every scanned document id when several files passed", async () => {
|
||||
apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" });
|
||||
|
||||
await workOrderUpliftsApi.create(7, {
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceDocumentIds: [22, 23],
|
||||
});
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", {
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceDocumentIds: [22, 23],
|
||||
});
|
||||
});
|
||||
|
||||
it("uploads one evidence file and reads the pending document id", async () => {
|
||||
apiPostForm.mockResolvedValueOnce({
|
||||
data: { id: 22, scanStatus: "Pending", originalFileName: "quote.pdf" },
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ describe("submitWorkOrderUplift", () => {
|
|||
it("creates without an evidence id when no file is attached", async () => {
|
||||
const collaborators = deps();
|
||||
|
||||
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: null }, collaborators);
|
||||
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [] }, collaborators);
|
||||
|
||||
expect(collaborators.uploadEvidence).not.toHaveBeenCalled();
|
||||
expect(collaborators.create).toHaveBeenCalledWith(7, {
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
evidenceDocumentId: undefined,
|
||||
evidenceDocumentIds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -35,13 +36,14 @@ describe("submitWorkOrderUplift", () => {
|
|||
getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Passed" }),
|
||||
});
|
||||
|
||||
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators);
|
||||
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators);
|
||||
|
||||
expect(collaborators.uploadEvidence).toHaveBeenCalledWith(7, quote);
|
||||
expect(collaborators.create).toHaveBeenCalledWith(7, {
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceDocumentIds: [22],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -52,7 +54,7 @@ describe("submitWorkOrderUplift", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators),
|
||||
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators),
|
||||
).rejects.toThrow(/rejected/i);
|
||||
expect(collaborators.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -65,9 +67,35 @@ describe("submitWorkOrderUplift", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators),
|
||||
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators),
|
||||
).rejects.toThrow(/still being scanned/i);
|
||||
expect(collaborators.create).not.toHaveBeenCalled();
|
||||
expect(collaborators.sleep).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it("uploads every file and sends each passed document id", async () => {
|
||||
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
|
||||
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
|
||||
const collaborators = deps({
|
||||
uploadEvidence: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 22, scanStatus: "Passed" })
|
||||
.mockResolvedValueOnce({ id: 23, scanStatus: "Passed" }),
|
||||
});
|
||||
|
||||
await submitWorkOrderUplift(
|
||||
7,
|
||||
{ amount: 25, notes: "Leak", files: [quote, photo] },
|
||||
collaborators,
|
||||
);
|
||||
|
||||
expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(1, 7, quote);
|
||||
expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(2, 7, photo);
|
||||
expect(collaborators.create).toHaveBeenCalledWith(7, {
|
||||
amount: 25,
|
||||
notes: "Leak",
|
||||
evidenceDocumentId: 22,
|
||||
evidenceDocumentIds: [22, 23],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue