feat(servers): adicionar importação em massa via CSV
- Expõe endpoint bulk que lê arquivos ;, cria servidores e retorna falhas - Cria DTOs e logs de validação para informar erros por linha - Implementa modal no header com upload CSV e download de templatemaster
parent
61b3af4c53
commit
b6ba3b8593
|
|
@ -1,5 +1,6 @@
|
|||
package com.hitcommunications.servermanager.controllers;
|
||||
|
||||
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
|
||||
import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
|
||||
import com.hitcommunications.servermanager.model.dtos.ServerDTO;
|
||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
||||
|
|
@ -9,6 +10,7 @@ import jakarta.validation.Valid;
|
|||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -45,6 +47,11 @@ public class ServersController {
|
|||
return ResponseEntity.ok().body(serversService.countAllByType());
|
||||
}
|
||||
|
||||
@PostMapping("/bulk")
|
||||
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
|
||||
return ResponseEntity.ok().body(serversService.bulkCreate(file));
|
||||
}
|
||||
|
||||
@GetMapping("/application/{application}")
|
||||
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
||||
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
||||
|
|
@ -66,4 +73,3 @@ public class ServersController {
|
|||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.hitcommunications.servermanager.model.dtos;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record BulkServerImportResponse(
|
||||
int total,
|
||||
int succeeded,
|
||||
int failed,
|
||||
List<FailedRow> failures
|
||||
) {
|
||||
public record FailedRow(
|
||||
int line,
|
||||
String error,
|
||||
String raw
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,26 @@ package com.hitcommunications.servermanager.services;
|
|||
|
||||
import com.hitcommunications.servermanager.mappers.ServersMapper;
|
||||
import com.hitcommunications.servermanager.model.Servers;
|
||||
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
|
||||
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow;
|
||||
import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
|
||||
import com.hitcommunications.servermanager.model.dtos.ServerDTO;
|
||||
import com.hitcommunications.servermanager.model.enums.Applications;
|
||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
||||
import com.hitcommunications.servermanager.repositories.ServersRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
|
@ -67,6 +76,77 @@ public class ServersService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
public BulkServerImportResponse bulkCreate(MultipartFile file) {
|
||||
List<FailedRow> failures = new ArrayList<>();
|
||||
int succeeded = 0;
|
||||
int processed = 0;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
int lineNumber = 0;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
lineNumber++;
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] columns = parseColumns(line);
|
||||
if (isHeaderRow(columns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
processed++;
|
||||
try {
|
||||
NewServerDTO dto = toNewServerDTO(columns);
|
||||
create(dto);
|
||||
succeeded++;
|
||||
} catch (Exception ex) {
|
||||
failures.add(new FailedRow(lineNumber, ex.getMessage(), line));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Erro ao ler arquivo CSV.", e);
|
||||
}
|
||||
|
||||
int failed = failures.size();
|
||||
return new BulkServerImportResponse(processed, succeeded, failed, failures);
|
||||
}
|
||||
|
||||
private String[] parseColumns(String line) {
|
||||
String[] rawColumns = line.split(";");
|
||||
String[] columns = new String[rawColumns.length];
|
||||
for (int i = 0; i < rawColumns.length; i++) {
|
||||
columns[i] = rawColumns[i].trim();
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
private NewServerDTO toNewServerDTO(String[] columns) {
|
||||
if (columns.length < 8) {
|
||||
throw new IllegalArgumentException("Linha incompleta. Esperado 8 colunas.");
|
||||
}
|
||||
|
||||
String name = columns[0];
|
||||
String ip = columns[1];
|
||||
Integer port = Integer.parseInt(columns[2]);
|
||||
String user = columns[3];
|
||||
String password = columns[4];
|
||||
ServersType type = ServersType.valueOf(columns[5].toUpperCase());
|
||||
Applications application = Applications.valueOf(columns[6].toUpperCase());
|
||||
DatabaseType dbType = DatabaseType.valueOf(columns[7].toUpperCase());
|
||||
|
||||
return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
|
||||
}
|
||||
|
||||
private boolean isHeaderRow(String[] columns) {
|
||||
if (columns.length < 8) {
|
||||
return false;
|
||||
}
|
||||
return "name".equalsIgnoreCase(columns[0]) &&
|
||||
"ip".equalsIgnoreCase(columns[1]) &&
|
||||
"port".equalsIgnoreCase(columns[2]);
|
||||
}
|
||||
|
||||
public List<ServerDTO> getAll() {
|
||||
return repo.findAll()
|
||||
.stream()
|
||||
|
|
@ -97,4 +177,3 @@ public class ServersService {
|
|||
repo.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { HeaderActions } from "./header/HeaderActions";
|
|||
import { HeaderBrand } from "./header/HeaderBrand";
|
||||
import { ProfileModal } from "./header/ProfileModal";
|
||||
import { ServerModal } from "./header/ServerModal";
|
||||
import { BulkImportModal } from "./header/BulkImportModal";
|
||||
import type { Applications, DatabaseType, ServersType } from "../types/enums";
|
||||
import type { User } from "../types/User";
|
||||
import type { ProfileFormState, ServerFormState } from "./header/types";
|
||||
import type { BulkImportResult } from "../types/BulkImport";
|
||||
|
||||
type ModalType = "addServer" | "editProfile" | null;
|
||||
type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
|
||||
|
||||
interface HeaderProps {
|
||||
currentUser: User | null;
|
||||
|
|
@ -47,6 +49,10 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
|||
const [profileForm, setProfileForm] = useState<ProfileFormState>(defaultProfileForm);
|
||||
const [serverLoading, setServerLoading] = useState(false);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [bulkFile, setBulkFile] = useState<File | null>(null);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [bulkResult, setBulkResult] = useState<BulkImportResult | null>(null);
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
|
|
@ -68,6 +74,9 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
|||
setActiveModal(null);
|
||||
setServerForm(defaultServerForm);
|
||||
setProfileForm((prev) => ({ ...prev, password: "" }));
|
||||
setBulkFile(null);
|
||||
setBulkResult(null);
|
||||
setBulkError(null);
|
||||
};
|
||||
|
||||
const handleServerFormChange = (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
|
|
@ -128,6 +137,47 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
|||
}
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
if (!bulkFile) {
|
||||
toast.error("Selecione um arquivo CSV para importar.");
|
||||
return;
|
||||
}
|
||||
setBulkLoading(true);
|
||||
setBulkError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", bulkFile);
|
||||
const { data } = await api.post<BulkImportResult>("/api/servers/bulk", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setBulkResult(data);
|
||||
toast.success(`Importação concluída: ${data.succeeded} sucesso(s).`);
|
||||
await Promise.resolve(onServerCreated?.());
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.message || "Falha ao importar servidores.";
|
||||
setBulkError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const sample = [
|
||||
"name;ip;port;user;password;type;application;dbType",
|
||||
"app-server;192.168.0.10;22;deploy;changeMe;PRODUCTION;HITMANAGER;POSTGRESQL",
|
||||
].join("\n");
|
||||
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "servers_template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={Styles.wrapper}>
|
||||
|
|
@ -137,6 +187,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
|||
onToggleMenu={toggleMenu}
|
||||
onAddServer={() => openModal("addServer")}
|
||||
onEditProfile={() => openModal("editProfile")}
|
||||
onBulkCreate={() => openModal("bulkImport")}
|
||||
/>
|
||||
</header>
|
||||
|
||||
|
|
@ -162,6 +213,18 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
|
|||
onChange={handleProfileFormChange}
|
||||
onSubmit={handleProfileSubmit}
|
||||
/>
|
||||
|
||||
<BulkImportModal
|
||||
isOpen={activeModal === "bulkImport"}
|
||||
file={bulkFile}
|
||||
loading={bulkLoading}
|
||||
result={bulkResult}
|
||||
error={bulkError}
|
||||
onClose={closeModal}
|
||||
onFileChange={setBulkFile}
|
||||
onSubmit={handleBulkSubmit}
|
||||
onDownloadTemplate={handleDownloadTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import type { ChangeEvent } from "react";
|
||||
import type { BulkImportResult } from "../../types/BulkImport";
|
||||
|
||||
interface BulkImportModalProps {
|
||||
isOpen: boolean;
|
||||
file: File | null;
|
||||
loading: boolean;
|
||||
result: BulkImportResult | null;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onFileChange: (file: File | null) => void;
|
||||
onSubmit: () => void;
|
||||
onDownloadTemplate: () => void;
|
||||
}
|
||||
|
||||
export const BulkImportModal = ({
|
||||
isOpen,
|
||||
file,
|
||||
loading,
|
||||
result,
|
||||
error,
|
||||
onClose,
|
||||
onFileChange,
|
||||
onSubmit,
|
||||
onDownloadTemplate,
|
||||
}: BulkImportModalProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = event.target.files?.[0];
|
||||
onFileChange(selected ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={Styles.modalOverlay} role="dialog" aria-modal="true">
|
||||
<div className={Styles.modal}>
|
||||
<div className={Styles.modalHeader}>
|
||||
<h2 className={Styles.modalTitle}>Cadastro em massa</h2>
|
||||
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={Styles.modalBody}>
|
||||
<div className={Styles.uploadCard}>
|
||||
<label htmlFor="bulk-file" className={Styles.dropLabel}>
|
||||
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
Arraste e solte ou clique para procurar
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="bulk-file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<div className={Styles.dropzone} onClick={() => document.getElementById("bulk-file")?.click()}>
|
||||
{file ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-text">{file.name}</p>
|
||||
<p className="text-xs text-text-secondary">{(file.size / 1024).toFixed(1)} KB</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-text-secondary">Nenhum arquivo selecionado.</p>
|
||||
)}
|
||||
</div>
|
||||
<p className={Styles.helperText}>
|
||||
Estrutura esperada: <code>name;ip;port;user;password;type;application;dbType</code>
|
||||
</p>
|
||||
<div className={Styles.actionsRow}>
|
||||
<button type="button" className={Styles.secondaryButton} onClick={onDownloadTemplate}>
|
||||
Baixar CSV de exemplo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={Styles.primaryButton}
|
||||
disabled={!file || loading}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{loading ? "Importando..." : "Importar arquivo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={Styles.errorText}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div className={Styles.resultCard}>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Stat label="Processados" value={result.total} />
|
||||
<Stat label="Sucesso" value={result.succeeded} accent />
|
||||
<Stat label="Falhas" value={result.failed} danger />
|
||||
</div>
|
||||
{result.failed > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
|
||||
<ul className={Styles.failureList}>
|
||||
{result.failures.map((failure) => (
|
||||
<li key={failure.line}>
|
||||
<span className="font-semibold">Linha {failure.line}</span>: {failure.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Stat = ({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
danger,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
accent?: boolean;
|
||||
danger?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex flex-col rounded-lg border px-4 py-3 text-sm ${
|
||||
accent ? "border-accent/40 bg-accent/10 text-accent" : danger ? "border-red-200 bg-red-50 text-red-600" : "border-cardBorder bg-white text-text"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">{label}</span>
|
||||
<span className="text-2xl font-bold leading-tight">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Styles = {
|
||||
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4",
|
||||
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl",
|
||||
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
|
||||
modalTitle: "text-lg font-semibold text-text",
|
||||
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
|
||||
modalBody: "space-y-5 pt-4",
|
||||
uploadCard: "rounded-xl border border-dashed border-cardBorder bg-white/70 p-6 shadow-inner space-y-4",
|
||||
dropLabel: "flex flex-col gap-1 text-center",
|
||||
dropzone: "flex flex-col items-center justify-center rounded-lg border border-cardBorder bg-bg px-4 py-6 text-center cursor-pointer hover:border-accent hover:bg-white transition-colors",
|
||||
helperText: "text-xs text-text-secondary",
|
||||
actionsRow: "flex flex-wrap items-center gap-3",
|
||||
secondaryButton: "rounded-md border border-cardBorder px-4 py-2 text-sm font-medium text-text hover:bg-bg",
|
||||
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
|
||||
errorText: "text-sm text-red-600",
|
||||
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
|
||||
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
|
||||
};
|
||||
|
|
@ -3,10 +3,18 @@ interface HeaderActionsProps {
|
|||
onToggleMenu: () => void;
|
||||
onAddServer: () => void;
|
||||
onEditProfile: () => void;
|
||||
onBulkCreate: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export const HeaderActions = ({ isMenuOpen, onToggleMenu, onAddServer, onEditProfile, onLogout }: HeaderActionsProps) => {
|
||||
export const HeaderActions = ({
|
||||
isMenuOpen,
|
||||
onToggleMenu,
|
||||
onAddServer,
|
||||
onEditProfile,
|
||||
onBulkCreate,
|
||||
onLogout,
|
||||
}: HeaderActionsProps) => {
|
||||
return (
|
||||
<div className={Styles.actions}>
|
||||
<div className="relative">
|
||||
|
|
@ -24,6 +32,9 @@ export const HeaderActions = ({ isMenuOpen, onToggleMenu, onAddServer, onEditPro
|
|||
<button type="button" className={Styles.dropdownItem} onClick={onAddServer}>
|
||||
Adicionar servidor
|
||||
</button>
|
||||
<button type="button" className={Styles.dropdownItem} onClick={onBulkCreate}>
|
||||
Cadastro em massa
|
||||
</button>
|
||||
<button type="button" className={Styles.dropdownItem} onClick={onEditProfile}>
|
||||
Editar perfil
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
export interface BulkImportFailure {
|
||||
line: number;
|
||||
error: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface BulkImportResult {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
failures: BulkImportFailure[];
|
||||
}
|
||||
Loading…
Reference in New Issue