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.
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)}"