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("/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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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"`
|
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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue