feat:(finance) Implementado sistema de edicao em massa para gastos extras

This commit is contained in:
NANDO9322 2026-02-12 09:51:27 -03:00
parent 95a4e441c1
commit da3754068b
6 changed files with 240 additions and 41 deletions

View file

@ -284,6 +284,7 @@ func main() {
financeGroup.GET("/fot-search", financeHandler.SearchFot) financeGroup.GET("/fot-search", financeHandler.SearchFot)
financeGroup.GET("/professionals", financeHandler.SearchProfessionals) financeGroup.GET("/professionals", financeHandler.SearchProfessionals)
financeGroup.GET("/price", financeHandler.GetPrice) financeGroup.GET("/price", financeHandler.GetPrice)
financeGroup.POST("/bulk/extras", financeHandler.BulkUpdate)
financeGroup.PUT("/:id", financeHandler.Update) financeGroup.PUT("/:id", financeHandler.Update)
financeGroup.DELETE("/:id", financeHandler.Delete) financeGroup.DELETE("/:id", financeHandler.Delete)
} }

View file

@ -0,0 +1,35 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: finance.sql
package generated
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const bulkUpdateExtras = `-- name: BulkUpdateExtras :exec
UPDATE financial_transactions
SET
valor_extra = COALESCE(valor_extra, 0) + $1,
descricao_extra = CASE
WHEN descricao_extra IS NULL OR descricao_extra = '' THEN $2
ELSE descricao_extra || ' + ' || $2
END,
total_pagar = valor_free + (COALESCE(valor_extra, 0) + $1)
WHERE id = ANY($3::uuid[])
`
type BulkUpdateExtrasParams struct {
ValorExtra pgtype.Numeric `json:"valor_extra"`
DescricaoExtra pgtype.Text `json:"descricao_extra"`
Ids []pgtype.UUID `json:"ids"`
}
func (q *Queries) BulkUpdateExtras(ctx context.Context, arg BulkUpdateExtrasParams) error {
_, err := q.db.Exec(ctx, bulkUpdateExtras, arg.ValorExtra, arg.DescricaoExtra, arg.Ids)
return err
}

View file

@ -0,0 +1,10 @@
-- name: BulkUpdateExtras :exec
UPDATE financial_transactions
SET
valor_extra = COALESCE(valor_extra, 0) + @valor_extra,
descricao_extra = CASE
WHEN descricao_extra IS NULL OR descricao_extra = '' THEN @descricao_extra
ELSE descricao_extra || ' + ' || @descricao_extra
END,
total_pagar = valor_free + (COALESCE(valor_extra, 0) + @valor_extra)
WHERE id = ANY(@ids::uuid[]);

View file

@ -35,6 +35,29 @@ type TransactionRequest struct {
PgtoOk bool `json:"pgto_ok"` PgtoOk bool `json:"pgto_ok"`
} }
type BulkUpdateRequest struct {
IDs []string `json:"ids"`
ValorExtra float64 `json:"valor_extra"`
DescricaoExtra string `json:"descricao_extra"`
}
func (h *Handler) BulkUpdate(c *gin.Context) {
var req BulkUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
regiao := c.GetString("regiao")
err := h.service.BulkUpdate(c.Request.Context(), req.IDs, req.ValorExtra, req.DescricaoExtra, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated successfully"})
}
// Helper: Parse Date String to pgtype.Date // Helper: Parse Date String to pgtype.Date
func parseDate(dateStr string) pgtype.Date { func parseDate(dateStr string) pgtype.Date {
if dateStr == "" { if dateStr == "" {

View file

@ -49,6 +49,32 @@ func (s *Service) Update(ctx context.Context, params generated.UpdateTransaction
return txn, nil return txn, nil
} }
func (s *Service) BulkUpdate(ctx context.Context, ids []string, valorExtra float64, descricaoExtra string, regiao string) error {
// Convert string IDs to pgtype.UUID
var pgIDs []pgtype.UUID
for _, id := range ids {
var u pgtype.UUID
if err := u.Scan(id); err == nil {
pgIDs = append(pgIDs, u)
}
}
if len(pgIDs) == 0 {
return nil
}
err := s.queries.BulkUpdateExtras(ctx, generated.BulkUpdateExtrasParams{
ValorExtra: toNumeric(valorExtra),
DescricaoExtra: pgtype.Text{String: descricaoExtra, Valid: true}, // Allow empty description? Yes.
Ids: pgIDs,
})
if err != nil {
return err
}
return nil
}
func (s *Service) Delete(ctx context.Context, id pgtype.UUID, regiao string) error { func (s *Service) Delete(ctx context.Context, id pgtype.UUID, regiao string) error {
// First fetch to get FotID // First fetch to get FotID
txn, err := s.queries.GetTransaction(ctx, generated.GetTransactionParams{ txn, err := s.queries.GetTransaction(ctx, generated.GetTransactionParams{

View file

@ -130,6 +130,14 @@ const Finance: React.FC = () => {
const [proFunctions, setProFunctions] = useState<any[]>([]); // Functions of selected professional const [proFunctions, setProFunctions] = useState<any[]>([]); // Functions of selected professional
const [selectedProId, setSelectedProId] = useState<string | null>(null); const [selectedProId, setSelectedProId] = useState<string | null>(null);
// Bulk Edit State
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
const [bulkFormData, setBulkFormData] = useState({
valorExtra: 0,
descricaoExtra: "",
});
// Validations // Validations
const validateCpf = (cpf: string) => { const validateCpf = (cpf: string) => {
// Simple length check for now, can be enhanced // Simple length check for now, can be enhanced
@ -428,46 +436,7 @@ const Finance: React.FC = () => {
} }
}; };
// Auto-Pricing Effect
useEffect(() => {
const fetchPrice = async () => {
if (!formData.tipoEvento || !formData.tipoServico) return;
// Skip auto-pricing if we are initializing the edit form
if (isEditInitializing.current) {
isEditInitializing.current = false;
return;
}
// Don't overwrite if we are editing and the types haven't changed from the original
if (selectedTransaction &&
formData.tipoEvento === selectedTransaction.tipoEvento &&
formData.tipoServico === selectedTransaction.tipoServico) {
return;
}
const token = localStorage.getItem("token");
if (!token) return;
try {
const res = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(formData.tipoEvento)}&service=${encodeURIComponent(formData.tipoServico)}`, {
headers: {
"Authorization": `Bearer ${token}`,
"x-regiao": localStorage.getItem("photum_selected_region") || "SP"
}
});
if (res.ok) {
const data = await res.json();
if (data.valor !== undefined) {
setFormData(prev => ({ ...prev, valorFree: data.valor }));
}
}
} catch (e) {
console.error(e);
}
};
fetchPrice();
}, [formData.tipoEvento, formData.tipoServico, selectedTransaction]);
const handleProSearch = async (query: string) => { const handleProSearch = async (query: string) => {
setProQuery(query); setProQuery(query);
@ -499,6 +468,64 @@ const Finance: React.FC = () => {
} }
}; };
// Bulk Selection Logic
const toggleSelection = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const toggleSelectAll = () => {
if (selectedIds.size === sortedTransactions.length && sortedTransactions.length > 0) {
setSelectedIds(new Set());
} else {
const newSet = new Set(sortedTransactions.map(t => t.id).filter(id => id)); // ensure valid IDs
setSelectedIds(newSet);
}
};
const handleBulkUpdate = async () => {
if (selectedIds.size === 0) return;
if (!confirm(`Confirma atualização de ${selectedIds.size} itens?`)) return;
const token = localStorage.getItem("token");
if (!token) return;
try {
const res = await fetch(`${API_BASE_URL}/api/finance/bulk/extras`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"x-regiao": localStorage.getItem("photum_selected_region") || "SP"
},
body: JSON.stringify({
ids: Array.from(selectedIds),
valor_extra: bulkFormData.valorExtra,
descricao_extra: bulkFormData.descricaoExtra
})
});
if (res.ok) {
alert("Itens atualizados com sucesso!");
setShowBulkEditModal(false);
setSelectedIds(new Set());
setBulkFormData({ valorExtra: 0, descricaoExtra: "" });
loadTransactions(); // Refresh list
} else {
const err = await res.json();
alert(`Erro ao atualizar: ${err.error || "Erro desconhecido"}`);
}
} catch (e) {
console.error(e);
alert("Erro de conexão ao atualizar.");
}
};
const selectProfessional = (pro: any) => { const selectProfessional = (pro: any) => {
// Parse functions // Parse functions
let funcs = []; let funcs = [];
@ -582,8 +609,9 @@ const Finance: React.FC = () => {
console.error("Error fetching FOT details for edit:", err); console.error("Error fetching FOT details for edit:", err);
} }
} else if (t.fot) { } else if (t.fot) {
// Fallback if no ID but we have the number // Fallback if no ID but we have the number (Legacy records)
setFotQuery(String(t.fot)); setFotQuery(String(t.fot));
handleAutoFill(t.fot); // Fetch details using FOT number
} }
// Fetch professional functions if professional name is present // Fetch professional functions if professional name is present
@ -1018,6 +1046,19 @@ const Finance: React.FC = () => {
<div className="h-3 min-w-[1800px]"></div> <div className="h-3 min-w-[1800px]"></div>
</div> </div>
{/* Bulk Actions Bar */}
{selectedIds.size > 0 && (
<div className="bg-blue-50 border-b p-2 flex justify-between items-center px-4 sticky left-0 z-10">
<span className="text-sm font-bold text-blue-800">{selectedIds.size} itens selecionados</span>
<button
onClick={() => setShowBulkEditModal(true)}
className="bg-blue-600 text-white px-3 py-1 rounded text-xs hover:bg-blue-700 shadow"
>
Editar Selecionados
</button>
</div>
)}
<div <div
ref={tableScrollRef} ref={tableScrollRef}
onScroll={() => handleScroll('table')} onScroll={() => handleScroll('table')}
@ -1026,6 +1067,14 @@ const Finance: React.FC = () => {
<table className="w-full text-xs text-left whitespace-nowrap min-w-[1800px]"> <table className="w-full text-xs text-left whitespace-nowrap min-w-[1800px]">
<thead className="bg-gray-100 border-b"> <thead className="bg-gray-100 border-b">
<tr> <tr>
<th className="px-3 py-2 w-10 text-center">
<input
type="checkbox"
checked={sortedTransactions.length > 0 && selectedIds.size === sortedTransactions.length}
onChange={toggleSelectAll}
className="rounded text-blue-600 focus:ring-blue-500"
/>
</th>
{[ {[
{ label: "FOT", key: "fot" }, { label: "FOT", key: "fot" },
{ label: "Data Evento", key: "data" }, { label: "Data Evento", key: "data" },
@ -1108,9 +1157,17 @@ const Finance: React.FC = () => {
return ( return (
<React.Fragment key={t.id}> <React.Fragment key={t.id}>
<tr <tr
className={`hover:bg-gray-50 cursor-pointer ${isNewFot ? "border-t-[3px] border-gray-400" : ""}`} className={`hover:bg-gray-50 cursor-pointer ${isNewFot ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
onClick={() => handleEdit(t)} onClick={() => handleEdit(t)}
> >
<td className="px-3 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(t.id!)}
onChange={(e) => { e.stopPropagation(); toggleSelection(t.id!); }}
className="rounded text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-3 py-2 font-bold">{t.fot || "?"}</td> <td className="px-3 py-2 font-bold">{t.fot || "?"}</td>
<td className="px-3 py-2">{t.data}</td> <td className="px-3 py-2">{t.data}</td>
<td className="px-3 py-2">{t.curso}</td> <td className="px-3 py-2">{t.curso}</td>
@ -1433,6 +1490,53 @@ const Finance: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Bulk Edit Modal */}
{showBulkEditModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
<div className="p-4 border-b flex justify-between items-center bg-gray-50">
<h2 className="text-lg font-bold text-gray-800">Editar {selectedIds.size} Itens</h2>
<button onClick={() => setShowBulkEditModal(false)} className="p-1 hover:bg-gray-200 rounded">
<X size={20}/>
</button>
</div>
<div className="p-6 flex flex-col gap-4">
<div className="bg-blue-50 border border-blue-200 p-3 rounded text-xs text-blue-800">
Atenção: Os valores inseridos serão <strong>adicionados</strong> aos valores existentes. A descrição será concatenada.
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Adicionar Valor Extra (R$)</label>
<input
type="number"
step="0.01"
className="w-full border rounded px-2 py-1.5 mt-1"
value={bulkFormData.valorExtra}
onChange={e => setBulkFormData({...bulkFormData, valorExtra: parseFloat(e.target.value) || 0})}
placeholder="Ex: 50.00"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Adicionar Descrição (Será concatenada)</label>
<input
type="text"
className="w-full border rounded px-2 py-1.5 mt-1"
value={bulkFormData.descricaoExtra}
onChange={e => setBulkFormData({...bulkFormData, descricaoExtra: e.target.value})}
placeholder="Ex: + Ajuda de Custo"
/>
</div>
</div>
<div className="p-4 border-t bg-gray-50 flex justify-end gap-2">
<button onClick={() => setShowBulkEditModal(false)} className="px-4 py-2 border rounded hover:bg-gray-100 text-sm">Cancelar</button>
<button onClick={handleBulkUpdate} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium">
Salvar Alterações
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };