feat:(finance) Implementado sistema de edicao em massa para gastos extras
This commit is contained in:
parent
95a4e441c1
commit
da3754068b
6 changed files with 240 additions and 41 deletions
|
|
@ -284,6 +284,7 @@ func main() {
|
|||
financeGroup.GET("/fot-search", financeHandler.SearchFot)
|
||||
financeGroup.GET("/professionals", financeHandler.SearchProfessionals)
|
||||
financeGroup.GET("/price", financeHandler.GetPrice)
|
||||
financeGroup.POST("/bulk/extras", financeHandler.BulkUpdate)
|
||||
financeGroup.PUT("/:id", financeHandler.Update)
|
||||
financeGroup.DELETE("/:id", financeHandler.Delete)
|
||||
}
|
||||
|
|
|
|||
35
backend/internal/db/generated/finance.sql.go
Normal file
35
backend/internal/db/generated/finance.sql.go
Normal 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
|
||||
}
|
||||
10
backend/internal/db/queries/finance.sql
Normal file
10
backend/internal/db/queries/finance.sql
Normal 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[]);
|
||||
|
|
@ -35,6 +35,29 @@ type TransactionRequest struct {
|
|||
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
|
||||
func parseDate(dateStr string) pgtype.Date {
|
||||
if dateStr == "" {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,32 @@ func (s *Service) Update(ctx context.Context, params generated.UpdateTransaction
|
|||
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 {
|
||||
// First fetch to get FotID
|
||||
txn, err := s.queries.GetTransaction(ctx, generated.GetTransactionParams{
|
||||
|
|
|
|||
|
|
@ -130,6 +130,14 @@ const Finance: React.FC = () => {
|
|||
const [proFunctions, setProFunctions] = useState<any[]>([]); // Functions of selected professional
|
||||
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
|
||||
const validateCpf = (cpf: string) => {
|
||||
// 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) => {
|
||||
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) => {
|
||||
// Parse functions
|
||||
let funcs = [];
|
||||
|
|
@ -582,8 +609,9 @@ const Finance: React.FC = () => {
|
|||
console.error("Error fetching FOT details for edit:", err);
|
||||
}
|
||||
} 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));
|
||||
handleAutoFill(t.fot); // Fetch details using FOT number
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
{/* 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
|
||||
ref={tableScrollRef}
|
||||
onScroll={() => handleScroll('table')}
|
||||
|
|
@ -1026,6 +1067,14 @@ const Finance: React.FC = () => {
|
|||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[1800px]">
|
||||
<thead className="bg-gray-100 border-b">
|
||||
<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: "Data Evento", key: "data" },
|
||||
|
|
@ -1108,9 +1157,17 @@ const Finance: React.FC = () => {
|
|||
return (
|
||||
<React.Fragment key={t.id}>
|
||||
<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)}
|
||||
>
|
||||
<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">{t.data}</td>
|
||||
<td className="px-3 py-2">{t.curso}</td>
|
||||
|
|
@ -1433,6 +1490,53 @@ const Finance: React.FC = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue