kotsu-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kotsu_cli-0.1.0.dist-info/METADATA +252 -0
- kotsu_cli-0.1.0.dist-info/RECORD +33 -0
- kotsu_cli-0.1.0.dist-info/WHEEL +4 -0
- kotsu_cli-0.1.0.dist-info/entry_points.txt +3 -0
- kotsu_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- money_cli/__init__.py +3 -0
- money_cli/__main__.py +197 -0
- money_cli/armazenamento/__init__.py +4 -0
- money_cli/armazenamento/backup.py +70 -0
- money_cli/armazenamento/banco.py +525 -0
- money_cli/armazenamento/caminhos.py +72 -0
- money_cli/armazenamento/migracoes/0001_inicial.sql +35 -0
- money_cli/armazenamento/migracoes/0002_recorrencia.sql +21 -0
- money_cli/armazenamento/migracoes/0003_mesclagem.sql +54 -0
- money_cli/armazenamento/migracoes/__init__.py +32 -0
- money_cli/armazenamento/preferencias.py +31 -0
- money_cli/armazenamento/trava.py +123 -0
- money_cli/armazenamento/troca.py +285 -0
- money_cli/dominio/__init__.py +5 -0
- money_cli/dominio/dinheiro.py +10 -0
- money_cli/dominio/filtro.py +63 -0
- money_cli/dominio/linha.py +179 -0
- money_cli/dominio/mes.py +57 -0
- money_cli/dominio/mescla.py +440 -0
- money_cli/dominio/parcelas.py +52 -0
- money_cli/dominio/recorrencia.py +20 -0
- money_cli/dominio/tipos.py +95 -0
- money_cli/dominio/totais.py +17 -0
- money_cli/tela/__init__.py +1 -0
- money_cli/tela/app.py +756 -0
- money_cli/tela/cores.py +31 -0
- money_cli/tela/perguntas.py +144 -0
- money_cli/tela/temas.py +74 -0
money_cli/tela/app.py
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"""Tela principal: mês, contas, lançamentos e a linha do rodapé (lançar, filtrar, editar)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.widgets import DataTable, Footer, Input, Static
|
|
15
|
+
from textual.widgets.data_table import RowDoesNotExist
|
|
16
|
+
|
|
17
|
+
from money_cli.armazenamento import preferencias, troca
|
|
18
|
+
from money_cli.armazenamento.banco import Banco
|
|
19
|
+
from money_cli.dominio import dinheiro, parcelas
|
|
20
|
+
from money_cli.dominio.filtro import SEM_FILTRO, Filtro, interpretar_filtro
|
|
21
|
+
from money_cli.dominio.linha import ErroDeLinha, LinhaInterpretada, interpretar, montar
|
|
22
|
+
from money_cli.dominio.mes import NOMES, Mes
|
|
23
|
+
from money_cli.dominio.tipos import Alcance, Categoria, Conta, Lancamento, NovoLancamento, Tipo
|
|
24
|
+
from money_cli.dominio.totais import TotaisMes
|
|
25
|
+
from money_cli.tela import cores
|
|
26
|
+
from money_cli.tela.perguntas import (
|
|
27
|
+
EscolherCor,
|
|
28
|
+
PedirTexto,
|
|
29
|
+
SimOuNao,
|
|
30
|
+
SoEstaOuTodas,
|
|
31
|
+
SoEsteOuProximos,
|
|
32
|
+
)
|
|
33
|
+
from money_cli.tela.temas import Tema, proximo, tema_por_nome
|
|
34
|
+
|
|
35
|
+
DICA = "ex.: 89,90 mercado #alimentação @nubank +3500 salário @itaú 120 geladeira 10x"
|
|
36
|
+
DICA_FILTRO = "filtrar: @conta #categoria @conta #categoria ou só o nome"
|
|
37
|
+
POR_PAGINA = 200 # lançamentos por página da lista (AGENTS.md: listas paginadas)
|
|
38
|
+
MESES_A_FRENTE = 24 # recorrentes só são geradas até hoje + 24 meses (D36)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Modo(Enum):
|
|
42
|
+
"""O que a linha do rodapé faz no Enter."""
|
|
43
|
+
|
|
44
|
+
LANCAR = "lançar"
|
|
45
|
+
FILTRAR = "filtrar"
|
|
46
|
+
EDITAR = "editar"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MoneyApp(App[None]):
|
|
50
|
+
TITLE = "Money CLI"
|
|
51
|
+
ENABLE_COMMAND_PALETTE = False
|
|
52
|
+
CSS = """
|
|
53
|
+
Screen { layout: vertical; }
|
|
54
|
+
#topo { height: auto; padding: 1 2 0 2; }
|
|
55
|
+
#contas { height: auto; max-height: 8; padding: 0 2; margin-top: 1; }
|
|
56
|
+
#lancamentos { height: 1fr; margin: 1 2 0 2; }
|
|
57
|
+
#vazio { height: 1fr; padding: 1 2; color: $text-muted; }
|
|
58
|
+
#linha { margin: 0 1; }
|
|
59
|
+
#previa { height: auto; min-height: 1; padding: 0 2; }
|
|
60
|
+
DataTable { border: round $primary; background: $background; }
|
|
61
|
+
DataTable > .datatable--header { background: $panel; color: $primary; }
|
|
62
|
+
DataTable > .datatable--even-row { background: $surface; }
|
|
63
|
+
DataTable > .datatable--odd-row { background: $background; }
|
|
64
|
+
DataTable > .datatable--cursor { background: $primary; color: $background; }
|
|
65
|
+
Footer { background: $panel; }
|
|
66
|
+
"""
|
|
67
|
+
BINDINGS = [
|
|
68
|
+
Binding("left", "mes(-1)", "Mês anterior"),
|
|
69
|
+
Binding("right", "mes(1)", "Mês seguinte"),
|
|
70
|
+
Binding("l", "lancar", "Lançar"),
|
|
71
|
+
Binding("slash", "filtrar", "Filtrar"),
|
|
72
|
+
Binding("e", "editar", "Editar"),
|
|
73
|
+
Binding("c", "copiar", "Copiar"),
|
|
74
|
+
Binding("d", "excluir", "Excluir"),
|
|
75
|
+
Binding("r", "renomear", "Renomear"),
|
|
76
|
+
Binding("m", "mensal", "Todo mês"),
|
|
77
|
+
Binding("b", "backup", "Backup"),
|
|
78
|
+
Binding("t", "tema", "Tema"),
|
|
79
|
+
Binding("v", "conferir", "Conferir"),
|
|
80
|
+
Binding("q", "quit", "Sair"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self, banco: Banco, hoje: dt.date | None = None, pasta_tema: Path | None = None
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.banco = banco
|
|
88
|
+
# `pasta_tema`: onde ler e gravar tema.toml; None (testes) não lê nem grava (D37).
|
|
89
|
+
self._pasta_tema = pasta_tema
|
|
90
|
+
saved = preferencias.ler_tema(pasta_tema) if pasta_tema else None
|
|
91
|
+
self.tema_atual: Tema = tema_por_nome(saved)
|
|
92
|
+
self._usar_cores(self.tema_atual)
|
|
93
|
+
self.hoje = hoje or dt.date.today()
|
|
94
|
+
self.mes = Mes.de(self.hoje)
|
|
95
|
+
self.filtro: Filtro = SEM_FILTRO
|
|
96
|
+
self._filtro_texto = "" # como o filtro foi digitado, para reabrir com ele
|
|
97
|
+
self._modo = Modo.LANCAR
|
|
98
|
+
self._edicao: tuple[Lancamento, Alcance] | None = None
|
|
99
|
+
self._recado: Text | None = None
|
|
100
|
+
# Estado da lista paginada do mês.
|
|
101
|
+
self._quantidade = 0
|
|
102
|
+
self._carregados = 0
|
|
103
|
+
self._contas: dict[int, Conta] = {}
|
|
104
|
+
self._categorias: dict[int, str] = {}
|
|
105
|
+
|
|
106
|
+
def compose(self) -> ComposeResult:
|
|
107
|
+
yield Static(id="topo")
|
|
108
|
+
yield Static(id="contas")
|
|
109
|
+
yield DataTable(id="lancamentos", cursor_type="row", zebra_stripes=True)
|
|
110
|
+
yield Static(id="vazio")
|
|
111
|
+
# Sem selecionar tudo ao focar: copiar e editar abrem a linha preenchida.
|
|
112
|
+
yield Input(placeholder=DICA, id="linha", select_on_focus=False)
|
|
113
|
+
yield Static(id="previa")
|
|
114
|
+
yield Footer()
|
|
115
|
+
|
|
116
|
+
def _usar_cores(self, tema: Tema) -> None:
|
|
117
|
+
cores.aplicar(tema.receita, tema.despesa, tema.neutro)
|
|
118
|
+
|
|
119
|
+
def action_tema(self) -> None:
|
|
120
|
+
"""Alterna entre os temas e lembra a escolha em tema.toml (D37)."""
|
|
121
|
+
self.tema_atual = proximo(self.tema_atual)
|
|
122
|
+
self._usar_cores(self.tema_atual)
|
|
123
|
+
self.theme = self.tema_atual.nome
|
|
124
|
+
self.atualizar()
|
|
125
|
+
gravou = True
|
|
126
|
+
if self._pasta_tema is not None:
|
|
127
|
+
gravou = preferencias.gravar_tema(self._pasta_tema, self.tema_atual.nome)
|
|
128
|
+
if gravou:
|
|
129
|
+
self.notify(self.tema_atual.rotulo, timeout=3)
|
|
130
|
+
else:
|
|
131
|
+
self.notify(
|
|
132
|
+
f"{self.tema_atual.rotulo} (não foi possível lembrar a escolha)",
|
|
133
|
+
severity="warning",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def on_mount(self) -> None:
|
|
137
|
+
for tema in (self.tema_atual, proximo(self.tema_atual)):
|
|
138
|
+
self.register_theme(tema.para_textual())
|
|
139
|
+
self.theme = self.tema_atual.nome
|
|
140
|
+
tabela = self.query_one("#lancamentos", DataTable)
|
|
141
|
+
tabela.add_columns("Data", "Descrição", "Categoria", "Conta", "Valor")
|
|
142
|
+
self.query_one("#linha", Input).display = False
|
|
143
|
+
self.atualizar()
|
|
144
|
+
|
|
145
|
+
def action_backup(self) -> None:
|
|
146
|
+
"""Backup manual: uma cópia nova a cada pedido, na pasta `backups/`."""
|
|
147
|
+
try:
|
|
148
|
+
destino = self.banco.backup_manual()
|
|
149
|
+
except OSError:
|
|
150
|
+
self.notify(
|
|
151
|
+
"Não foi possível fazer o backup. Confira a pasta de dados.",
|
|
152
|
+
severity="warning",
|
|
153
|
+
timeout=10,
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
if destino is None:
|
|
157
|
+
self.notify("Banco em memória: não há arquivo para copiar.", severity="warning")
|
|
158
|
+
else:
|
|
159
|
+
self.notify(f"Backup salvo em {destino}", timeout=8)
|
|
160
|
+
|
|
161
|
+
def action_conferir(self) -> None:
|
|
162
|
+
"""Mostra o código de conferência: igual ao do outro aparelho = mesmos dados (D39)."""
|
|
163
|
+
self.notify(f"Código de conferência: {troca.codigo(self.banco)}", timeout=15)
|
|
164
|
+
|
|
165
|
+
# --- navegação e desenho -------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def action_mes(self, passo: int) -> None:
|
|
168
|
+
self.mes = self.mes.somar(passo)
|
|
169
|
+
self.atualizar(topo=True)
|
|
170
|
+
|
|
171
|
+
def atualizar(self, manter: int | None = None, topo: bool = False) -> None:
|
|
172
|
+
"""Redesenha tudo a partir do banco.
|
|
173
|
+
|
|
174
|
+
O cursor volta para o lançamento `manter` (padrão: o selecionado agora); se ele saiu
|
|
175
|
+
do mês ou do filtro, fica no vizinho. `topo=True` (outro mês, outro filtro) começa
|
|
176
|
+
do primeiro.
|
|
177
|
+
"""
|
|
178
|
+
self.banco.estender_recorrencias(min(self.mes, Mes.de(self.hoje).somar(MESES_A_FRENTE)))
|
|
179
|
+
tabela = self.query_one("#lancamentos", DataTable)
|
|
180
|
+
linha_antes = 0 if topo or not tabela.row_count else tabela.cursor_row
|
|
181
|
+
carregados_antes = 0 if topo else self._carregados
|
|
182
|
+
if manter is None and not topo:
|
|
183
|
+
manter = self.selecionado_id()
|
|
184
|
+
|
|
185
|
+
contas = self.banco.listar_contas()
|
|
186
|
+
self._contas = {c.id: c for c in contas}
|
|
187
|
+
self._categorias = {c.id: c.nome for c in self.banco.listar_categorias()}
|
|
188
|
+
|
|
189
|
+
totais = self.banco.totais_do_mes(self.mes, self.filtro)
|
|
190
|
+
self._quantidade = totais.quantidade
|
|
191
|
+
self.query_one("#topo", Static).update(self.texto_topo(totais))
|
|
192
|
+
self.query_one("#contas", Static).update(self.texto_contas(contas))
|
|
193
|
+
|
|
194
|
+
tabela.clear()
|
|
195
|
+
self._carregados = 0
|
|
196
|
+
self.carregar_mais(tabela)
|
|
197
|
+
# Recarrega as páginas que já estavam abertas, para o cursor não pular ao topo.
|
|
198
|
+
while self._carregados < min(carregados_antes, self._quantidade):
|
|
199
|
+
self.carregar_mais(tabela)
|
|
200
|
+
if tabela.row_count:
|
|
201
|
+
alvo = min(linha_antes, tabela.row_count - 1)
|
|
202
|
+
if manter is not None:
|
|
203
|
+
try:
|
|
204
|
+
alvo = tabela.get_row_index(str(manter))
|
|
205
|
+
except RowDoesNotExist: # saiu do mês ou do filtro: fica no vizinho
|
|
206
|
+
pass
|
|
207
|
+
tabela.move_cursor(row=alvo, animate=False)
|
|
208
|
+
|
|
209
|
+
vazio = self._quantidade == 0
|
|
210
|
+
tabela.display = not vazio
|
|
211
|
+
aviso = self.query_one("#vazio", Static)
|
|
212
|
+
aviso.display = vazio
|
|
213
|
+
if self.filtro.ativo:
|
|
214
|
+
aviso.update("Nada neste mês com esse filtro. Esc tira o filtro.")
|
|
215
|
+
else:
|
|
216
|
+
aviso.update("Nenhum lançamento neste mês. Tecle l para lançar.")
|
|
217
|
+
|
|
218
|
+
def texto_topo(self, totais: TotaisMes) -> Text:
|
|
219
|
+
tema = self.tema_atual
|
|
220
|
+
quantidade = totais.quantidade
|
|
221
|
+
rosto = tema.feliz if totais.saldo >= 0 else tema.triste
|
|
222
|
+
topo = Text.assemble(
|
|
223
|
+
(rosto, tema.aviso),
|
|
224
|
+
" ",
|
|
225
|
+
(f" {self.mes.nome.capitalize()} ", f"bold {tema.fundo} on {tema.destaque}"),
|
|
226
|
+
(f" {quantidade} lançamento{'' if quantidade == 1 else 's'}", cores.NEUTRO),
|
|
227
|
+
("\n", ""),
|
|
228
|
+
("♡ receitas ", tema.secundaria),
|
|
229
|
+
(dinheiro.formatar(totais.receitas), f"bold {cores.RECEITA}"),
|
|
230
|
+
(" ✧ despesas ", tema.secundaria),
|
|
231
|
+
(dinheiro.formatar(totais.despesas), f"bold {cores.DESPESA}"),
|
|
232
|
+
(" ☆ saldo ", tema.secundaria),
|
|
233
|
+
(dinheiro.formatar(totais.saldo), f"bold {cores.do_saldo(totais.saldo)}"),
|
|
234
|
+
)
|
|
235
|
+
if self.filtro.ativo: # segunda linha do topo, para não quebrar a primeira
|
|
236
|
+
topo.append("\nfiltro: ", style=cores.NEUTRO)
|
|
237
|
+
topo.append_text(self.rotulo_filtro(self.filtro))
|
|
238
|
+
topo.append(" (Esc tira)", style=cores.NEUTRO)
|
|
239
|
+
return topo
|
|
240
|
+
|
|
241
|
+
def texto_contas(self, contas: list[Conta]) -> Text:
|
|
242
|
+
"""Uma linha por conta, na cor dela; sem lançamento no mês, "zerada" (D9, D29)."""
|
|
243
|
+
if not contas:
|
|
244
|
+
return Text("Sem contas ainda.", style=cores.NEUTRO)
|
|
245
|
+
por_conta = self.banco.totais_por_conta(self.mes, self.filtro)
|
|
246
|
+
if self.filtro.conta_id is not None: # filtro por conta: só ela
|
|
247
|
+
contas = [c for c in contas if c.id == self.filtro.conta_id]
|
|
248
|
+
largura = max(len(c.nome) for c in contas)
|
|
249
|
+
maior = max((x.despesas for x in por_conta.values()), default=0)
|
|
250
|
+
linhas = []
|
|
251
|
+
for conta in contas:
|
|
252
|
+
tema = self.tema_atual
|
|
253
|
+
texto = Text.assemble(
|
|
254
|
+
(f"{tema.marcador} ", conta.cor), (conta.nome.ljust(largura), f"bold {conta.cor}")
|
|
255
|
+
)
|
|
256
|
+
t = por_conta.get(conta.id)
|
|
257
|
+
if t is None:
|
|
258
|
+
texto.append(f" {tema.zerada}", style=cores.NEUTRO)
|
|
259
|
+
else:
|
|
260
|
+
cheias = round(10 * t.despesas / maior) if maior else 0
|
|
261
|
+
texto.append(" " + tema.barra_cheia * cheias, style=conta.cor)
|
|
262
|
+
texto.append(tema.barra_vazia * (10 - cheias), style=cores.NEUTRO)
|
|
263
|
+
texto.append(" receitas ")
|
|
264
|
+
texto.append(dinheiro.formatar(t.receitas).rjust(12), style=cores.RECEITA)
|
|
265
|
+
texto.append(" despesas ")
|
|
266
|
+
texto.append(dinheiro.formatar(t.despesas).rjust(12), style=cores.DESPESA)
|
|
267
|
+
linhas.append(texto)
|
|
268
|
+
return Text("\n").join(linhas)
|
|
269
|
+
|
|
270
|
+
def carregar_mais(self, tabela: DataTable) -> None:
|
|
271
|
+
"""Acrescenta a próxima página do mês na lista."""
|
|
272
|
+
pagina = self.banco.lancamentos_do_mes(self.mes, POR_PAGINA, self._carregados, self.filtro)
|
|
273
|
+
for item in pagina:
|
|
274
|
+
conta = self._contas.get(item.conta_id)
|
|
275
|
+
descricao = item.descricao
|
|
276
|
+
if item.parcela:
|
|
277
|
+
descricao = f"{descricao} {item.parcela.rotulo}"
|
|
278
|
+
elif item.grupo_recorrencia:
|
|
279
|
+
descricao = f"{descricao} ↻"
|
|
280
|
+
tabela.add_row(
|
|
281
|
+
item.data.strftime("%d/%m"),
|
|
282
|
+
descricao,
|
|
283
|
+
self._categorias.get(item.categoria_id, ""),
|
|
284
|
+
Text(conta.nome, style=conta.cor) if conta else "",
|
|
285
|
+
Text(
|
|
286
|
+
dinheiro.formatar(item.valor),
|
|
287
|
+
style=cores.do_saldo(item.valor_com_sinal),
|
|
288
|
+
justify="right",
|
|
289
|
+
),
|
|
290
|
+
key=str(item.id),
|
|
291
|
+
)
|
|
292
|
+
self._carregados += len(pagina)
|
|
293
|
+
|
|
294
|
+
def on_data_table_row_highlighted(self, evento: DataTable.RowHighlighted) -> None:
|
|
295
|
+
tabela = evento.data_table
|
|
296
|
+
if evento.cursor_row >= tabela.row_count - 1 and self._carregados < self._quantidade:
|
|
297
|
+
self.carregar_mais(tabela)
|
|
298
|
+
|
|
299
|
+
# --- linha do rodapé -----------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def abrir_linha(self, modo: Modo, texto: str = "") -> None:
|
|
302
|
+
self._modo = modo
|
|
303
|
+
self.refresh_bindings()
|
|
304
|
+
campo = self.query_one("#linha", Input)
|
|
305
|
+
campo.placeholder = DICA_FILTRO if modo is Modo.FILTRAR else DICA
|
|
306
|
+
campo.value = texto
|
|
307
|
+
campo.cursor_position = len(texto)
|
|
308
|
+
campo.display = True
|
|
309
|
+
campo.focus()
|
|
310
|
+
self._recado = None
|
|
311
|
+
self.mostrar_previa(texto)
|
|
312
|
+
|
|
313
|
+
def fechar_linha(self) -> None:
|
|
314
|
+
campo = self.query_one("#linha", Input)
|
|
315
|
+
campo.value = ""
|
|
316
|
+
campo.display = False
|
|
317
|
+
self._modo = Modo.LANCAR
|
|
318
|
+
self._edicao = None
|
|
319
|
+
self._recado = None
|
|
320
|
+
self.query_one("#previa", Static).update("")
|
|
321
|
+
self.query_one("#lancamentos", DataTable).focus()
|
|
322
|
+
self.refresh_bindings()
|
|
323
|
+
|
|
324
|
+
def action_lancar(self) -> None:
|
|
325
|
+
self.abrir_linha(Modo.LANCAR)
|
|
326
|
+
|
|
327
|
+
def action_filtrar(self) -> None:
|
|
328
|
+
self.abrir_linha(Modo.FILTRAR, self._filtro_texto)
|
|
329
|
+
|
|
330
|
+
def action_copiar(self) -> None:
|
|
331
|
+
"""Linha preenchida com o selecionado, sem data e sem parcelas (D27)."""
|
|
332
|
+
item = self.selecionado()
|
|
333
|
+
if item is not None:
|
|
334
|
+
self.abrir_linha(Modo.LANCAR, self.linha_de(item, com_data=False))
|
|
335
|
+
|
|
336
|
+
@work(exclusive=True)
|
|
337
|
+
async def action_editar(self) -> None:
|
|
338
|
+
"""Linha preenchida com o lançamento inteiro; Enter altera o mesmo (D28)."""
|
|
339
|
+
item = self.selecionado()
|
|
340
|
+
if item is None:
|
|
341
|
+
return
|
|
342
|
+
alcance = await self.perguntar_alcance(item, "Editar", Alcance.SO_ESTA)
|
|
343
|
+
if alcance is None:
|
|
344
|
+
return
|
|
345
|
+
self._edicao = (item, alcance)
|
|
346
|
+
texto = self.linha_de(item, com_data=not _todas(item, alcance))
|
|
347
|
+
self.abrir_linha(Modo.EDITAR, texto)
|
|
348
|
+
|
|
349
|
+
async def perguntar_alcance(
|
|
350
|
+
self, item: Lancamento, acao: str, padrao: Alcance
|
|
351
|
+
) -> Alcance | None:
|
|
352
|
+
""" "Só esta ou todas?" nas parcelas (D12); "só este mês ou os próximos?" nas
|
|
353
|
+
recorrentes (D36). Lançamento comum não pergunta e devolve `padrao`."""
|
|
354
|
+
if item.parcela:
|
|
355
|
+
return await self.push_screen_wait(SoEstaOuTodas(acao, item.parcela.rotulo))
|
|
356
|
+
if item.grupo_recorrencia:
|
|
357
|
+
return await self.push_screen_wait(SoEsteOuProximos(acao))
|
|
358
|
+
return padrao
|
|
359
|
+
|
|
360
|
+
def linha_de(self, item: Lancamento, com_data: bool) -> str:
|
|
361
|
+
return montar(
|
|
362
|
+
item.valor,
|
|
363
|
+
item.tipo,
|
|
364
|
+
item.descricao,
|
|
365
|
+
self._contas[item.conta_id].nome,
|
|
366
|
+
self._categorias.get(item.categoria_id),
|
|
367
|
+
item.data if com_data else None,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
371
|
+
# Com a linha aberta, "/" é texto (datas como 15/10): esconde "Filtrar" do rodapé.
|
|
372
|
+
if action == "filtrar" and self.focused is self.query_one("#linha", Input):
|
|
373
|
+
return False
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
def on_key(self, evento) -> None:
|
|
377
|
+
if evento.key != "escape":
|
|
378
|
+
return
|
|
379
|
+
if self.focused is self.query_one("#linha", Input):
|
|
380
|
+
evento.stop()
|
|
381
|
+
self.fechar_linha()
|
|
382
|
+
elif self.filtro.ativo and len(self.screen_stack) == 1:
|
|
383
|
+
evento.stop()
|
|
384
|
+
self.definir_filtro(SEM_FILTRO, "")
|
|
385
|
+
|
|
386
|
+
def on_input_changed(self, evento: Input.Changed) -> None:
|
|
387
|
+
if evento.input.id == "linha":
|
|
388
|
+
self.mostrar_previa(evento.value)
|
|
389
|
+
|
|
390
|
+
def on_input_submitted(self, evento: Input.Submitted) -> None:
|
|
391
|
+
if evento.input.id != "linha":
|
|
392
|
+
return
|
|
393
|
+
if self._modo is Modo.FILTRAR:
|
|
394
|
+
self.aplicar_filtro(evento.value)
|
|
395
|
+
elif self._modo is Modo.EDITAR:
|
|
396
|
+
self.salvar_edicao(evento.value)
|
|
397
|
+
else:
|
|
398
|
+
self.salvar_linha(evento.value)
|
|
399
|
+
|
|
400
|
+
def mostrar_previa(self, texto: str) -> None:
|
|
401
|
+
if not self.query_one("#linha", Input).display:
|
|
402
|
+
return # linha fechada: a prévia guarda o último recado ("editado: ...")
|
|
403
|
+
previa = self.query_one("#previa", Static)
|
|
404
|
+
if self._modo is Modo.FILTRAR:
|
|
405
|
+
previa.update(self.previa_filtro(texto))
|
|
406
|
+
return
|
|
407
|
+
if not texto.strip():
|
|
408
|
+
# Depois de salvar, a linha fica vazia mas o recado "salvo: ..." continua.
|
|
409
|
+
previa.update(self._recado or Text(DICA, style=cores.NEUTRO))
|
|
410
|
+
return
|
|
411
|
+
self._recado = None
|
|
412
|
+
try:
|
|
413
|
+
linha = interpretar(texto, self.hoje)
|
|
414
|
+
if self._modo is Modo.EDITAR:
|
|
415
|
+
self.conferir_edicao(linha)
|
|
416
|
+
except ErroDeLinha as erro:
|
|
417
|
+
previa.update(Text(str(erro), style=cores.DESPESA))
|
|
418
|
+
return
|
|
419
|
+
if self._modo is Modo.EDITAR:
|
|
420
|
+
previa.update(Text.assemble(("editar: ", cores.NEUTRO), self.texto_previa(linha)))
|
|
421
|
+
else:
|
|
422
|
+
previa.update(self.texto_previa(linha))
|
|
423
|
+
|
|
424
|
+
def texto_previa(self, linha: LinhaInterpretada) -> Text:
|
|
425
|
+
cor_valor = cores.RECEITA if linha.tipo is Tipo.RECEITA else cores.DESPESA
|
|
426
|
+
partes: list[Text] = [
|
|
427
|
+
Text.assemble((linha.tipo.value, cor_valor), " ", (_valor(linha), f"bold {cor_valor}"))
|
|
428
|
+
]
|
|
429
|
+
if linha.descricao:
|
|
430
|
+
partes.append(Text(linha.descricao, style="bold"))
|
|
431
|
+
if linha.categoria:
|
|
432
|
+
existe = self.banco.categoria_por_nome(linha.categoria)
|
|
433
|
+
nome = existe.nome if existe else linha.categoria
|
|
434
|
+
partes.append(Text(f"#{nome}" + ("" if existe else " (nova)")))
|
|
435
|
+
conta = self.banco.conta_por_nome(linha.conta) if linha.conta else None
|
|
436
|
+
if conta:
|
|
437
|
+
partes.append(Text(f"@{conta.nome}", style=f"bold {conta.cor}"))
|
|
438
|
+
elif linha.conta:
|
|
439
|
+
partes.append(Text(f"@{linha.conta} (nova)", style="bold"))
|
|
440
|
+
else:
|
|
441
|
+
partes.append(Text("falta @conta", style=cores.DESPESA))
|
|
442
|
+
partes.append(Text(self.periodo(linha), style=cores.NEUTRO))
|
|
443
|
+
return Text(" · ").join(partes)
|
|
444
|
+
|
|
445
|
+
def periodo(self, linha: LinhaInterpretada) -> str:
|
|
446
|
+
if self._modo is Modo.EDITAR and self._edicao and _todas(*self._edicao):
|
|
447
|
+
item, alcance = self._edicao
|
|
448
|
+
if alcance is Alcance.PROXIMAS:
|
|
449
|
+
return "este mês e os próximos, datas mantidas"
|
|
450
|
+
return f"todas as {item.parcela.total} parcelas, datas mantidas"
|
|
451
|
+
if (
|
|
452
|
+
self._modo is Modo.LANCAR
|
|
453
|
+
and linha.parcelas == 1
|
|
454
|
+
and self.categoria_recorrente(linha.categoria)
|
|
455
|
+
):
|
|
456
|
+
return f"{linha.data.strftime('%d/%m/%Y')}, e todo mês"
|
|
457
|
+
return _periodo(linha)
|
|
458
|
+
|
|
459
|
+
def categoria_recorrente(self, nome: str | None) -> bool:
|
|
460
|
+
categoria = self.banco.categoria_por_nome(nome) if nome else None
|
|
461
|
+
return categoria is not None and categoria.recorrente
|
|
462
|
+
|
|
463
|
+
async def resolver_nomes(self, linha: LinhaInterpretada) -> tuple[Conta, int | None] | None:
|
|
464
|
+
"""Conta (obrigatória, D21/D22) e categoria da linha; cria as novas perguntando.
|
|
465
|
+
|
|
466
|
+
Devolve None se faltou a conta ou a pessoa cancelou; a prévia já diz o motivo.
|
|
467
|
+
"""
|
|
468
|
+
previa = self.query_one("#previa", Static)
|
|
469
|
+
if not linha.conta:
|
|
470
|
+
previa.update(Text("informe a conta com @nome", style=cores.DESPESA))
|
|
471
|
+
return None
|
|
472
|
+
conta = self.banco.conta_por_nome(linha.conta)
|
|
473
|
+
if conta is None:
|
|
474
|
+
cor = await self.push_screen_wait(EscolherCor(linha.conta, self.cor_sugerida()))
|
|
475
|
+
if cor is None:
|
|
476
|
+
previa.update(Text("cancelado: nada foi salvo", style=cores.NEUTRO))
|
|
477
|
+
return None
|
|
478
|
+
conta = self.banco.criar_conta(linha.conta, cor)
|
|
479
|
+
|
|
480
|
+
categoria: Categoria | None = None
|
|
481
|
+
if linha.categoria:
|
|
482
|
+
categoria = self.banco.categoria_por_nome(linha.categoria)
|
|
483
|
+
if categoria is None:
|
|
484
|
+
criar = await self.push_screen_wait(
|
|
485
|
+
SimOuNao(f"Criar a categoria {linha.categoria}?")
|
|
486
|
+
)
|
|
487
|
+
if not criar:
|
|
488
|
+
previa.update(Text("cancelado: nada foi salvo", style=cores.NEUTRO))
|
|
489
|
+
self.atualizar() # a conta pode ter sido criada
|
|
490
|
+
return None
|
|
491
|
+
repetir = await self.push_screen_wait(
|
|
492
|
+
SimOuNao(
|
|
493
|
+
f"Repetir todo mês os lançamentos de {linha.categoria}?",
|
|
494
|
+
"Aluguel, internet, salário... Enter = sim Esc = não",
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
categoria = self.banco.criar_categoria(linha.categoria, bool(repetir))
|
|
498
|
+
return conta, categoria.id if categoria else None
|
|
499
|
+
|
|
500
|
+
@work(exclusive=True)
|
|
501
|
+
async def salvar_linha(self, texto: str) -> None:
|
|
502
|
+
previa = self.query_one("#previa", Static)
|
|
503
|
+
try:
|
|
504
|
+
linha = interpretar(texto, self.hoje)
|
|
505
|
+
except ErroDeLinha as erro:
|
|
506
|
+
previa.update(Text(str(erro), style=cores.DESPESA))
|
|
507
|
+
return
|
|
508
|
+
nomes = await self.resolver_nomes(linha)
|
|
509
|
+
if nomes is None:
|
|
510
|
+
return
|
|
511
|
+
conta, categoria_id = nomes
|
|
512
|
+
|
|
513
|
+
recorrente = categoria_id is not None and any(
|
|
514
|
+
c.id == categoria_id and c.recorrente for c in self.banco.listar_categorias()
|
|
515
|
+
)
|
|
516
|
+
if recorrente and linha.parcelas > 1:
|
|
517
|
+
previa.update(
|
|
518
|
+
Text(
|
|
519
|
+
f"#{linha.categoria} já repete todo mês: tire o {linha.parcelas}x",
|
|
520
|
+
style=cores.DESPESA,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
return
|
|
524
|
+
grupo = self.banco.novo_grupo_parcela() if linha.parcelas > 1 else None
|
|
525
|
+
novos = parcelas.gerar(linha, conta.id, categoria_id, grupo)
|
|
526
|
+
if recorrente:
|
|
527
|
+
novos = [replace(novos[0], grupo_recorrencia=self.banco.novo_grupo_parcela())]
|
|
528
|
+
ids = self.banco.inserir_lancamentos(novos)
|
|
529
|
+
|
|
530
|
+
self._recado = Text.assemble(
|
|
531
|
+
("salvo: ", cores.NEUTRO), self.texto_previa_salva(linha, conta)
|
|
532
|
+
)
|
|
533
|
+
self.query_one("#linha", Input).value = ""
|
|
534
|
+
self.atualizar(manter=ids[0])
|
|
535
|
+
previa.update(self._recado)
|
|
536
|
+
|
|
537
|
+
def texto_previa_salva(self, linha: LinhaInterpretada, conta: Conta) -> Text:
|
|
538
|
+
cor_valor = cores.RECEITA if linha.tipo is Tipo.RECEITA else cores.DESPESA
|
|
539
|
+
return Text(" · ").join(
|
|
540
|
+
[
|
|
541
|
+
Text(_valor(linha), style=f"bold {cor_valor}"),
|
|
542
|
+
Text(linha.descricao or "(sem descrição)"),
|
|
543
|
+
Text(f"@{conta.nome}", style=f"bold {conta.cor}"),
|
|
544
|
+
Text(self.periodo(linha), style=cores.NEUTRO),
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
def cor_sugerida(self) -> str:
|
|
549
|
+
usadas = {c.cor for c in self.banco.listar_contas()}
|
|
550
|
+
livres = [c for c in cores.CONTAS if c not in usadas]
|
|
551
|
+
return livres[0] if livres else cores.CONTAS[len(usadas) % len(cores.CONTAS)]
|
|
552
|
+
|
|
553
|
+
# --- editar (D28) --------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
def conferir_edicao(self, linha: LinhaInterpretada) -> None:
|
|
556
|
+
if linha.parcelas > 1:
|
|
557
|
+
raise ErroDeLinha("o número de parcelas não muda ao editar")
|
|
558
|
+
if self._edicao and _todas(*self._edicao) and linha.com_data:
|
|
559
|
+
raise ErroDeLinha("a data muda só em um lançamento: edite com 'só esta'")
|
|
560
|
+
|
|
561
|
+
@work(exclusive=True)
|
|
562
|
+
async def salvar_edicao(self, texto: str) -> None:
|
|
563
|
+
if self._edicao is None:
|
|
564
|
+
return
|
|
565
|
+
item, alcance = self._edicao
|
|
566
|
+
previa = self.query_one("#previa", Static)
|
|
567
|
+
try:
|
|
568
|
+
linha = interpretar(texto, self.hoje)
|
|
569
|
+
self.conferir_edicao(linha)
|
|
570
|
+
except ErroDeLinha as erro:
|
|
571
|
+
previa.update(Text(str(erro), style=cores.DESPESA))
|
|
572
|
+
return
|
|
573
|
+
nomes = await self.resolver_nomes(linha)
|
|
574
|
+
if nomes is None:
|
|
575
|
+
return
|
|
576
|
+
conta, categoria_id = nomes
|
|
577
|
+
novo = NovoLancamento(
|
|
578
|
+
linha.data, linha.descricao, linha.valor, linha.tipo, conta.id, categoria_id
|
|
579
|
+
)
|
|
580
|
+
recado = Text.assemble(("editado: ", cores.NEUTRO), self.texto_previa_salva(linha, conta))
|
|
581
|
+
quantos = self.banco.editar(item.id, novo, alcance)
|
|
582
|
+
self.fechar_linha()
|
|
583
|
+
self.atualizar(manter=item.id)
|
|
584
|
+
if quantos > 1:
|
|
585
|
+
recado.append(f" ({quantos} parcelas)", style=cores.NEUTRO)
|
|
586
|
+
self.query_one("#previa", Static).update(recado)
|
|
587
|
+
|
|
588
|
+
# --- filtro (D12, D26) ---------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def resolver_filtro(self, texto: str) -> Filtro:
|
|
591
|
+
"""Texto do `/` em ids. Levanta `ErroDeLinha` com a mensagem para a prévia."""
|
|
592
|
+
digitado = interpretar_filtro(texto)
|
|
593
|
+
conta = categoria = None
|
|
594
|
+
if digitado.qualquer is not None:
|
|
595
|
+
nome = digitado.qualquer
|
|
596
|
+
conta = self.banco.conta_por_nome(nome)
|
|
597
|
+
categoria = self.banco.categoria_por_nome(nome)
|
|
598
|
+
if conta and categoria:
|
|
599
|
+
raise ErroDeLinha(f"há conta e categoria {nome}: use @{nome} ou #{nome}")
|
|
600
|
+
if not conta and not categoria:
|
|
601
|
+
raise ErroDeLinha(f"não há conta nem categoria {nome}")
|
|
602
|
+
if digitado.conta is not None:
|
|
603
|
+
conta = self.banco.conta_por_nome(digitado.conta)
|
|
604
|
+
if conta is None:
|
|
605
|
+
raise ErroDeLinha(f"não há a conta {digitado.conta}")
|
|
606
|
+
if digitado.categoria is not None:
|
|
607
|
+
categoria = self.banco.categoria_por_nome(digitado.categoria)
|
|
608
|
+
if categoria is None:
|
|
609
|
+
raise ErroDeLinha(f"não há a categoria {digitado.categoria}")
|
|
610
|
+
return Filtro(conta.id if conta else None, categoria.id if categoria else None)
|
|
611
|
+
|
|
612
|
+
def rotulo_filtro(self, filtro: Filtro) -> Text:
|
|
613
|
+
partes = []
|
|
614
|
+
if filtro.conta_id is not None:
|
|
615
|
+
conta = self._contas[filtro.conta_id]
|
|
616
|
+
partes.append(Text(f"@{conta.nome}", style=f"bold {conta.cor}"))
|
|
617
|
+
if filtro.categoria_id is not None:
|
|
618
|
+
nome = self._categorias[filtro.categoria_id]
|
|
619
|
+
partes.append(Text(f"#{nome}", style="bold"))
|
|
620
|
+
return Text(" ").join(partes)
|
|
621
|
+
|
|
622
|
+
def previa_filtro(self, texto: str) -> Text:
|
|
623
|
+
if not texto.strip():
|
|
624
|
+
if self.filtro.ativo:
|
|
625
|
+
return Text("Enter tira o filtro", style=cores.NEUTRO)
|
|
626
|
+
return Text(DICA_FILTRO, style=cores.NEUTRO)
|
|
627
|
+
try:
|
|
628
|
+
filtro = self.resolver_filtro(texto)
|
|
629
|
+
except ErroDeLinha as erro:
|
|
630
|
+
return Text(str(erro), style=cores.DESPESA)
|
|
631
|
+
return Text.assemble(("mostrar só ", cores.NEUTRO), self.rotulo_filtro(filtro))
|
|
632
|
+
|
|
633
|
+
def aplicar_filtro(self, texto: str) -> None:
|
|
634
|
+
try:
|
|
635
|
+
filtro = self.resolver_filtro(texto) if texto.strip() else SEM_FILTRO
|
|
636
|
+
except ErroDeLinha as erro:
|
|
637
|
+
self.query_one("#previa", Static).update(Text(str(erro), style=cores.DESPESA))
|
|
638
|
+
return
|
|
639
|
+
self.fechar_linha()
|
|
640
|
+
self.definir_filtro(filtro, texto.strip() if filtro.ativo else "")
|
|
641
|
+
|
|
642
|
+
def definir_filtro(self, filtro: Filtro, texto: str) -> None:
|
|
643
|
+
self.filtro = filtro
|
|
644
|
+
self._filtro_texto = texto
|
|
645
|
+
self.atualizar(topo=True)
|
|
646
|
+
|
|
647
|
+
# --- selecionar, excluir e renomear (D12) --------------------------------
|
|
648
|
+
|
|
649
|
+
def selecionado_id(self) -> int | None:
|
|
650
|
+
tabela = self.query_one("#lancamentos", DataTable)
|
|
651
|
+
if tabela.row_count == 0:
|
|
652
|
+
return None
|
|
653
|
+
chave = tabela.coordinate_to_cell_key(tabela.cursor_coordinate).row_key
|
|
654
|
+
return int(chave.value)
|
|
655
|
+
|
|
656
|
+
def selecionado(self) -> Lancamento | None:
|
|
657
|
+
tabela = self.query_one("#lancamentos", DataTable)
|
|
658
|
+
if not tabela.display:
|
|
659
|
+
return None
|
|
660
|
+
id_ = self.selecionado_id()
|
|
661
|
+
return self.banco.lancamento(id_) if id_ is not None else None
|
|
662
|
+
|
|
663
|
+
@work(exclusive=True)
|
|
664
|
+
async def action_excluir(self) -> None:
|
|
665
|
+
item = self.selecionado()
|
|
666
|
+
if item is None:
|
|
667
|
+
return
|
|
668
|
+
if item.parcela or item.grupo_recorrencia:
|
|
669
|
+
alcance = await self.perguntar_alcance(item, "Excluir", Alcance.SO_ESTA)
|
|
670
|
+
else:
|
|
671
|
+
ok = await self.push_screen_wait(
|
|
672
|
+
SimOuNao(f"Excluir {item.descricao or 'o lançamento'}?")
|
|
673
|
+
)
|
|
674
|
+
alcance = Alcance.SO_ESTA if ok else None
|
|
675
|
+
if alcance is None:
|
|
676
|
+
return
|
|
677
|
+
self.banco.excluir(item.id, alcance)
|
|
678
|
+
self.atualizar() # o excluído some; o cursor fica no vizinho
|
|
679
|
+
|
|
680
|
+
@work(exclusive=True)
|
|
681
|
+
async def action_mensal(self) -> None:
|
|
682
|
+
"""Liga ou desliga "todo mês" na categoria do selecionado (D36).
|
|
683
|
+
|
|
684
|
+
Ligar vale para os próximos lançamentos da categoria e já faz do selecionado o
|
|
685
|
+
começo da série. Desligar não mexe nas séries em andamento.
|
|
686
|
+
"""
|
|
687
|
+
item = self.selecionado()
|
|
688
|
+
if item is None:
|
|
689
|
+
return
|
|
690
|
+
if item.categoria_id is None:
|
|
691
|
+
self.notify("Este lançamento não tem categoria (#nome).", severity="warning")
|
|
692
|
+
return
|
|
693
|
+
nome = self._categorias[item.categoria_id]
|
|
694
|
+
categoria = self.banco.categoria_por_nome(nome)
|
|
695
|
+
if categoria.recorrente:
|
|
696
|
+
ok = await self.push_screen_wait(
|
|
697
|
+
SimOuNao(
|
|
698
|
+
f"Parar de marcar #{nome} como todo mês?",
|
|
699
|
+
"Lançamentos novos não repetem. Os que já repetem continuam"
|
|
700
|
+
" até você excluir (d).",
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
if ok:
|
|
704
|
+
self.banco.definir_recorrente(categoria.id, False)
|
|
705
|
+
self.notify(f"#{nome} deixou de repetir em lançamentos novos.")
|
|
706
|
+
else:
|
|
707
|
+
ok = await self.push_screen_wait(
|
|
708
|
+
SimOuNao(
|
|
709
|
+
f"Repetir todo mês os lançamentos de #{nome}?",
|
|
710
|
+
"Este lançamento vira o começo da repetição." if not item.parcela else "",
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
if ok:
|
|
714
|
+
self.banco.definir_recorrente(categoria.id, True)
|
|
715
|
+
self.banco.tornar_recorrente(item.id)
|
|
716
|
+
self.atualizar(manter=item.id)
|
|
717
|
+
|
|
718
|
+
@work(exclusive=True)
|
|
719
|
+
async def action_renomear(self) -> None:
|
|
720
|
+
item = self.selecionado()
|
|
721
|
+
if item is None:
|
|
722
|
+
return
|
|
723
|
+
alcance = await self.perguntar_alcance(item, "Renomear", Alcance.SO_ESTA)
|
|
724
|
+
if alcance is None:
|
|
725
|
+
return
|
|
726
|
+
nova = await self.push_screen_wait(PedirTexto("Nova descrição", item.descricao))
|
|
727
|
+
if nova is None:
|
|
728
|
+
return
|
|
729
|
+
self.banco.renomear(item.id, nova, alcance)
|
|
730
|
+
self.atualizar(manter=item.id)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _todas(item: Lancamento, alcance: Alcance) -> bool:
|
|
734
|
+
"""Alcance maior que um lançamento: todas as parcelas ou os próximos meses."""
|
|
735
|
+
if alcance is Alcance.PROXIMAS:
|
|
736
|
+
return item.grupo_recorrencia is not None
|
|
737
|
+
return item.parcela is not None and alcance is Alcance.TODAS
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _valor(linha: LinhaInterpretada) -> str:
|
|
741
|
+
if linha.parcelas == 1:
|
|
742
|
+
return dinheiro.formatar(linha.valor)
|
|
743
|
+
return (
|
|
744
|
+
f"{linha.parcelas}x de {dinheiro.formatar(linha.valor)} = {dinheiro.formatar(linha.total)}"
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _mes_curto(data: dt.date) -> str:
|
|
749
|
+
return f"{NOMES[data.month - 1][:3]}/{data.year}"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _periodo(linha: LinhaInterpretada) -> str:
|
|
753
|
+
if linha.parcelas == 1:
|
|
754
|
+
return linha.data.strftime("%d/%m/%Y")
|
|
755
|
+
ultima = parcelas.data_da_parcela(linha.data, linha.parcelas - 1)
|
|
756
|
+
return f"{_mes_curto(linha.data)} a {_mes_curto(ultima)}"
|