diff --git a/backend/src/main/java/com/hitcommunications/servermanager/controllers/ServersController.java b/backend/src/main/java/com/hitcommunications/servermanager/controllers/ServersController.java index 9450626..32aed7d 100644 --- a/backend/src/main/java/com/hitcommunications/servermanager/controllers/ServersController.java +++ b/backend/src/main/java/com/hitcommunications/servermanager/controllers/ServersController.java @@ -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 bulkCreate(@RequestParam("file") MultipartFile file) { + return ResponseEntity.ok().body(serversService.bulkCreate(file)); + } + @GetMapping("/application/{application}") public ResponseEntity> getByApplication(@PathVariable Applications application) { return ResponseEntity.ok().body(serversService.getByApplication(application)); @@ -66,4 +73,3 @@ public class ServersController { return ResponseEntity.noContent().build(); } } - diff --git a/backend/src/main/java/com/hitcommunications/servermanager/model/dtos/BulkServerImportResponse.java b/backend/src/main/java/com/hitcommunications/servermanager/model/dtos/BulkServerImportResponse.java new file mode 100644 index 0000000..fed4018 --- /dev/null +++ b/backend/src/main/java/com/hitcommunications/servermanager/model/dtos/BulkServerImportResponse.java @@ -0,0 +1,17 @@ +package com.hitcommunications.servermanager.model.dtos; + +import java.util.List; + +public record BulkServerImportResponse( + int total, + int succeeded, + int failed, + List failures +) { + public record FailedRow( + int line, + String error, + String raw + ) { + } +} diff --git a/backend/src/main/java/com/hitcommunications/servermanager/services/ServersService.java b/backend/src/main/java/com/hitcommunications/servermanager/services/ServersService.java index 9e0da7b..0fad71c 100644 --- a/backend/src/main/java/com/hitcommunications/servermanager/services/ServersService.java +++ b/backend/src/main/java/com/hitcommunications/servermanager/services/ServersService.java @@ -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 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 getAll() { return repo.findAll() .stream() @@ -97,4 +177,3 @@ public class ServersService { repo.deleteById(id); } } - diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 68a8127..9c5ed23 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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(defaultProfileForm); const [serverLoading, setServerLoading] = useState(false); const [profileLoading, setProfileLoading] = useState(false); + const [bulkFile, setBulkFile] = useState(null); + const [bulkLoading, setBulkLoading] = useState(false); + const [bulkResult, setBulkResult] = useState(null); + const [bulkError, setBulkError] = useState(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) => { @@ -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("/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 ( <>
@@ -137,6 +187,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat onToggleMenu={toggleMenu} onAddServer={() => openModal("addServer")} onEditProfile={() => openModal("editProfile")} + onBulkCreate={() => openModal("bulkImport")} />
@@ -162,6 +213,18 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat onChange={handleProfileFormChange} onSubmit={handleProfileSubmit} /> + + ); }; diff --git a/frontend/src/components/header/BulkImportModal.tsx b/frontend/src/components/header/BulkImportModal.tsx new file mode 100644 index 0000000..c68a15b --- /dev/null +++ b/frontend/src/components/header/BulkImportModal.tsx @@ -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) => { + const selected = event.target.files?.[0]; + onFileChange(selected ?? null); + }; + + return ( +
+
+
+

Cadastro em massa

+ +
+
+
+ + +
document.getElementById("bulk-file")?.click()}> + {file ? ( + <> +

{file.name}

+

{(file.size / 1024).toFixed(1)} KB

+ + ) : ( +

Nenhum arquivo selecionado.

+ )} +
+

+ Estrutura esperada: name;ip;port;user;password;type;application;dbType +

+
+ + +
+
+ + {error &&

{error}

} + + {result && ( +
+
+ + + +
+ {result.failed > 0 && ( +
+

Detalhes das falhas:

+
    + {result.failures.map((failure) => ( +
  • + Linha {failure.line}: {failure.error} +
  • + ))} +
+
+ )} +
+ )} +
+
+
+ ); +}; + +const Stat = ({ + label, + value, + accent, + danger, +}: { + label: string; + value: number; + accent?: boolean; + danger?: boolean; +}) => ( +
+ {label} + {value} +
+); + +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", +}; diff --git a/frontend/src/components/header/HeaderActions.tsx b/frontend/src/components/header/HeaderActions.tsx index c9a3c1b..885de5e 100644 --- a/frontend/src/components/header/HeaderActions.tsx +++ b/frontend/src/components/header/HeaderActions.tsx @@ -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 (
@@ -24,6 +32,9 @@ export const HeaderActions = ({ isMenuOpen, onToggleMenu, onAddServer, onEditPro + diff --git a/frontend/src/types/BulkImport.ts b/frontend/src/types/BulkImport.ts new file mode 100644 index 0000000..526e4b2 --- /dev/null +++ b/frontend/src/types/BulkImport.ts @@ -0,0 +1,12 @@ +export interface BulkImportFailure { + line: number; + error: string; + raw: string; +} + +export interface BulkImportResult { + total: number; + succeeded: number; + failed: number; + failures: BulkImportFailure[]; +}