Unzip budget-app v7 into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:40:02 +00:00
parent 2554e68315
commit c9fd26f1c7
94 changed files with 12098 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Copy this file to .env and change every value marked CHANGE ME.
# Database password (any long random string). CHANGE ME
POSTGRES_PASSWORD=change-me-db-password
# Signs login sessions. Generate one with: openssl rand -hex 32 CHANGE ME
SECRET_KEY=change-me-secret
# First administrator, created on the first start only. Change the password after signing in.
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me-admin
ADMIN_NAME=Administrator
# Interface language for new users: pt or en
DEFAULT_LANGUAGE=pt
# Ports on the server. Open http://<server>:8080 in a browser.
HTTP_PORT=8080
HTTPS_PORT=8443
# Leave as :80 for plain HTTP on your network.
# For HTTPS with Caddy's own certificate on your network, use for example: budget.local
# (and set HTTP_PORT=80, HTTPS_PORT=443, COOKIE_SECURE=true)
SITE_ADDRESS=:80
COOKIE_SECURE=false
SESSION_HOURS=12
# Nightly database backups, written to BACKUP_DIR on the server
BACKUP_DIR=./backups
BACKUP_TIME=02:30
BACKUP_KEEP_DAYS=30
TZ=Europe/Lisbon
+6
View File
@@ -0,0 +1,6 @@
.env
backups/
frontend/node_modules/
frontend/dist/
**/__pycache__/
backend/tests/test.db
+45
View File
@@ -0,0 +1,45 @@
# Changelog
## 7.0.0
- MFA (two-step sign-in) with an authenticator app (Microsoft Authenticator, Google Authenticator, Authy…): each user turns it on in My profile by scanning a QR code and confirming a code, and gets 8 one-time recovery codes. Sign-in then asks for the 6-digit code after the password. Each code works once; 5 wrong codes lock MFA sign-in for 5 minutes. Turning it off needs the password and a code. Administrators can reset a user's MFA (lost phone) from Users → Edit. Every MFA event is in the activity log.
- Users list: new MFA column (On/Off) and each user's picture.
- Light and dark mode: a switch in the top bar (and on the sign-in page) cycles Automatic → Light → Dark, and My profile → Appearance. The choice is saved per user.
- Profile picture: upload a JPG, PNG, GIF or WebP (up to 5 MB) in My profile; it is cropped to a square, reduced to 256 px and stored in the database, so it is in the backups. Shown in the top bar and the users list; initials when there is none.
- Upgrades keep existing data; migration 0007 only adds columns to users, and a backup is taken first.
## 6.0.0
- New role **Upper Management** (Gestão de Topo): sees every department, can switch between them, view budgets and reports, download them (budget file, PDF, Excel) and send them by email. It cannot create, edit, upload, submit, approve, reopen, ask for reopening or delete anything, and never appears as a workflow approver. A department is optional for these users.
- Budgets page: Upper Management users start on "All departments"; the template button is hidden for them.
- Upgrades keep existing data as before; the upgrade takes a backup first (migration 0006 changes no tables).
## 5.0.0
- Reopen requests: on an approved budget anyone can ask for it to be reopened, with a mandatory reason. The department's approvers and administrators see the request on the budget, accept it (the budget goes back to draft) or decline it with a reason, and are emailed about it when email is on. Approvers reopening directly must also give a reason. Every request is kept in the budget's history.
- The budget page has Download (the editable budget file, plus the two reports in PDF and Excel) and Send by email, in every status.
- Uploading a file for a department and year that already has a budget asks first ("the file already exists"). On OK, the current version is kept, renamed with the date and time (title and file name) and locked, and the upload becomes the current budget. Approved or in-review budgets can't be replaced until they are reopened.
- Archived versions are listed under Budgets → Show archived versions; they can be viewed, exported and emailed, and only administrators can delete them.
## 4.0.0
- Lines can be split over chosen months: tick the months (or a quarter or half-year) and the adjusted amount is divided equally over them; the other months stay at zero. The last chosen month takes the rounding cents.
- The line window shows a live month-by-month preview for the 12-month split and the chosen-months split.
- Excel template: new last column "Split over months" / "Dividir pelos meses" (for example 1-4 or Jan, Fev, Mar, Abr). Older templates still import.
- Rolling a budget over to the next year keeps the chosen months.
- Switching a line from monthly amounts to a split keeps its year total as the base.
## 3.0.0
- Send reports by email (PDF and/or Excel) from the Reports page, through the account set up by administrators.
- Email settings for administrators: SMTP server, security, login, sender, signature, and a copy of every message saved in the mailbox's Sent folder over IMAP. Passwords are stored encrypted.
- Mail log for administrators: who sent what to whom, delivery and Sent-copy status, CSV export.
- Activity log for administrators: every action by every user, with filters and CSV export. Report views, downloads, template downloads, sign-outs and failed sign-ins are now recorded too.
- Only administrators can delete a budget, in any status, with a typed confirmation.
- Upgrades: the API now asks the backup container for a fresh backup before every database change, even when the backup container itself is not restarted.
## 2.0.0
- Departments: budgets, categories and users belong to a department; the same year can exist once per department.
- Approval workflow per department, set by administrators: ordered steps approved by the department's approvers or by named users.
- Other departments can view and export a budget but not change or approve it.
- Excel template per department (Portuguese or English, blank or pre-filled) that can be filled in and imported.
- Department approvers can manage their department's categories; categories can be copied between departments.
- Upgrades keep existing data: a backup is written before the API starts and migrations run; version 1 data moves into the IT department.
## 1.0.0
- First release: cost lines with adjustment rules, xlsx import, cost report and monthly budget online, in PDF and Excel, PT/EN, roles, approval, audit log, nightly backups.
+185
View File
@@ -0,0 +1,185 @@
# Budget & Capex
A self-hosted web app for building yearly department budgets and the "Capex e Custos" cost report.
Users enter cost lines in the browser; the app produces the monthly budget (like the *Budget IT*
sheet) and the cost report (like *Capex_e_Custos_2027*), online and as PDF and Excel.
The interface and the reports are available in Portuguese and English.
## What's inside
| Container | Role |
| --- | --- |
| `web` | Caddy: serves the web app and forwards `/api` to the API. Optional HTTPS. |
| `api` | Python (FastAPI): business rules, Excel import, PDF and Excel exports. |
| `db` | PostgreSQL 16. All data lives in the `db-data` Docker volume. |
| `backup` | Dumps the database every night to `./backups` and keeps 30 days. |
## Install
You need a server on your network with Docker Engine and the Docker Compose plugin.
```sh
# 1. copy this folder to the server, then inside it:
cp .env.example .env
# 2. edit .env: set POSTGRES_PASSWORD, SECRET_KEY (openssl rand -hex 32) and ADMIN_PASSWORD
# 3. build and start
docker compose up -d --build
```
Open `http://<server-address>:8080` and sign in with `ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env`.
Change the password under **My profile** right away. The admin account is created only on the first start;
changing `.env` later does not change it.
## First steps
1. **Departments**: the app starts with an **IT** department. Add the others (F&B, Rooms, …); each new
department starts from the default chart of accounts, from no categories, or from a copy of another
department's categories.
2. **Budgets → Import Excel**: choose the department and upload `Budget para 2027.xlsx` (a copy is in
`backend/tests/fixtures/`) or a filled-in template (see below).
The import reads the *Sheet1* and *Budget IT* sheets: 54 lines, the 5.5% rate, every multiplier
(including the ×2.055 and ×3.055 hardware increases) and the remarks. Formulas such as `=5512*12`
are kept as typed calculations.
3. **Users → New user** for each colleague, with their department and role:
* **Viewer**: view and export every budget.
* **Upper Management**: view, download and email the budgets and reports of every department, switching
between departments; changes nothing. A department is optional.
* **Editor**: edit their own department's draft budgets and submit them for review.
* **Approver**: also approve their department's budgets and manage its categories.
* **Administrator**: everything, including departments, workflows, users and sites.
Everyone can view every department's budgets and reports; only the budget's own department can
change it, and only the people named in its workflow can approve it.
Each user can turn on **MFA** in *My profile* (authenticator app + recovery codes). If someone loses
their phone, an administrator opens *Users → Edit* and presses **Reset MFA**. MFA secrets are encrypted
with `SECRET_KEY`: keep that value when you upgrade, or everyone's MFA has to be reset.
4. **Reports**: pick the year, language and optionally one site, then *View online*, *PDF* or *Excel*.
## Entering data
* Each line has a **prior-year actual**, a **base** for the new year and an **adjustment**:
*year rate* (the "Inflation + adjustments" % on the budget page), a custom *%*, a *factor ×*,
or *none*. The adjusted value is rounded to cents per line.
* Amount fields accept calculations, as in Excel: `12*839,67+3292,56` or `=5512*12`.
Both `1 234,56` and `1,234.56` are understood.
* Months are **split over 12** (December absorbs the rounding cents), **split over chosen months**
(tick for example Jan to Apr: the adjusted amount is divided equally over those months and the rest
stay at zero), or typed one by one (**monthly amounts**), as the revenue lines are.
* Lines can belong to one **site** (Sagres, Quinta, Chiado, Oriente) or to the **group**. Filter any page
or report by site.
* **New year** can copy every line from an earlier year: last year's adjusted budget becomes the
new year's actual and base, which you then replace with real figures.
## Approval workflow
Each department has its own workflow, set by an administrator under **Departments**: an ordered list of
steps, each approved either by *the department's approvers* or by *selected users* (who can be in any
department, e.g. finance). With no steps set, one approval by the department's approvers is needed.
`Draft → step 1 → step 2 → … → Approved`. Only drafts can be edited. An editor submits; whoever may
approve the current step approves (moving it to the next step) or returns it to draft; an approver of
the department or an administrator can reopen an approved budget. Every change (who, when, old and new
value) is in **History** on the budget page.
## Reopening an approved budget
Anyone can press **Request reopening** on an approved budget and must say why. The department's approvers
and the administrators see the request on the budget (and get an email when email is set up), and
**Accept and reopen** it (it goes back to draft) or **Decline** it, giving a reason. Approvers can also
reopen directly, again with a reason. All requests stay in the budget's **History**.
## Download, change and upload again
On a budget page, **Download → Budget file** gives the budget as an editable Excel file (the template
filled with its lines). Change it and upload it with **Upload Excel** on the same page (or Budgets →
Import Excel). Because that budget already exists, the app asks first. On OK, the current version is
kept but renamed with the date and time, for example `Budget 2027 (2026-10-28 23:00)` and
`Orcamento_IT_2027_2026-10-28_2300.xlsx`, and locked so it can't be edited; the uploaded file becomes
the current budget. Only drafts can be replaced; an approved budget must be reopened first. Archived
versions are under **Budgets → Show archived versions**.
## Excel template
**Budgets → Excel template** downloads a workbook for a department and year, in Portuguese or English,
blank or pre-filled with the lines of an existing budget. It has an instructions sheet and a *Lines*
sheet with drop-down lists for category, site and adjustment type. Fill it in and upload it with
**Import Excel**; unknown categories are created in the department and anything skipped is listed.
## Email
Administrators set up the sending account under **Email → Sending account**: the SMTP server, port and
security (STARTTLS on 587 or SSL/TLS on 465), the login, the sender address and name, and an optional
signature. With **Save a copy in the Sent folder** on, every message is also stored over IMAP in the
mailbox's Sent folder (found automatically, or named by hand), just like a message sent from Outlook.
Gmail already keeps that copy for SMTP, so turn the option off there to avoid duplicates. For Gmail and
Microsoft 365, use an app password or an account with SMTP sign-in allowed. **Test connection** signs in
without sending; **Send test email** sends a short message.
Then every user gets **Send by email** on the Reports page: choose recipients, subject, message and the
reports to attach (PDF and/or Excel, in the language and site chosen on the page).
Passwords are stored encrypted with `SECRET_KEY`. If you change `SECRET_KEY`, enter them again.
## Logs
- **Activity** (administrators): who did what and when: sign-ins and failed sign-ins, budgets and
lines created, changed, imported, submitted, approved and deleted, reports viewed, downloaded and
emailed, and admin changes. Filter by user, area, action, date or text, and export to CSV.
- **Email → Mail log** (administrators): every message sent: who sent it, to whom, the subject,
message and attachments, whether it was delivered to the server, and whether the copy reached the
Sent folder. Also exportable to CSV.
Only administrators can delete a budget (in any status), from the budget's page. It asks you to type the
year to confirm; the deletion stays in the activity log.
## Backups and restore
Backups are written to `BACKUP_DIR` (default `./backups`) at `BACKUP_TIME` every night and once at start.
```sh
docker compose exec backup ls /backups
docker compose exec backup restore.sh /backups/budget_20270101_023000.sql.gz
docker compose restart api
```
Copy the `backups` folder to another machine or a NAS regularly; a backup on the same disk does not
protect against losing that disk.
## Updating
Your data lives in Docker volumes, not in this folder, so replacing the folder does not touch it.
1. Replace the folder contents with the new version, keeping your `.env` and `backups` folder.
2. Run `docker compose up -d --build`.
Before the API applies database changes (migrations), it asks the backup container for a fresh dump
(`backups/budget_..._before_upgrade.sql.gz`) and waits for it; if the backup fails, the upgrade does not
run. So there is always a backup from just before an upgrade.
Upgrading from version 1 moves all existing budgets, categories and users into the **IT** department.
Never run `docker compose down -v`: `-v` deletes the data volumes.
## HTTPS (optional)
Plain HTTP on port 8080 is the default, which is fine on a trusted network. For HTTPS, set in `.env`:
```
SITE_ADDRESS=budget.yourdomain.local
HTTP_PORT=80
HTTPS_PORT=443
COOKIE_SECURE=true
```
and make that name resolve to the server. Caddy issues a certificate from its own local authority for
internal names. Browsers will warn until you install its root certificate on each PC:
`docker compose cp web:/data/caddy/pki/authorities/local/root.crt caddy-root.crt`.
With a public domain pointing at the server, Caddy gets a Let's Encrypt certificate instead.
## Development
```sh
cd backend && pip install -r requirements.txt pytest httpx && python -m pytest tests # 44 tests
uvicorn app.main:app --reload # API on :8000 (SQLite)
cd frontend && npm install && npm run dev # web on :5173
```
API documentation is at `/api/docs` when the app is running.
+1
View File
@@ -0,0 +1 @@
7.0.0
+3
View File
@@ -0,0 +1,3 @@
tests
**/__pycache__
*.db
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
# WeasyPrint needs Pango; DejaVu fonts give the reports a consistent look.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini .
COPY alembic ./alembic
COPY app ./app
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh && useradd --system --uid 1000 app && chown -R app /srv
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"
ENTRYPOINT ["./docker-entrypoint.sh"]
+35
View File
@@ -0,0 +1,35 @@
[alembic]
prepend_sys_path = .
script_location = alembic
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+19
View File
@@ -0,0 +1,19 @@
from logging.config import fileConfig
from alembic import context
from app import models # noqa: F401
from app.db import Base, engine
if context.config.config_file_name:
fileConfig(context.config.config_file_name)
def run_migrations_online() -> None:
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
run_migrations_online()
+22
View File
@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,121 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-10-28 20:51:51.396857
"""
from alembic import op
import sqlalchemy as sa
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name_en', sa.String(length=200), nullable=False),
sa.Column('name_pt', sa.String(length=200), nullable=False),
sa.Column('section', sa.String(length=20), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('show_when_empty', sa.Boolean(), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name_en')
)
op.create_table('sites',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('language', sa.String(length=5), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('year_id', sa.Integer(), nullable=True),
sa.Column('entity', sa.String(length=40), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=True),
sa.Column('action', sa.String(length=40), nullable=False),
sa.Column('summary', sa.String(length=500), nullable=False),
sa.Column('changes', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
op.create_index(op.f('ix_audit_log_year_id'), 'audit_log', ['year_id'], unique=False)
op.create_table('budget_years',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('year', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('default_rate', sa.Numeric(precision=9, scale=6), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('notes', sa.Text(), nullable=False),
sa.Column('submitted_by_id', sa.Integer(), nullable=True),
sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('approved_by_id', sa.Integer(), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['approved_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['submitted_by_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('year')
)
op.create_table('cost_lines',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('year_id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('site_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.String(length=500), nullable=False),
sa.Column('remarks', sa.Text(), nullable=False),
sa.Column('prior_expr', sa.String(length=500), nullable=False),
sa.Column('prior_actual', sa.Numeric(precision=18, scale=6), nullable=False),
sa.Column('base_expr', sa.String(length=500), nullable=False),
sa.Column('base_amount', sa.Numeric(precision=18, scale=6), nullable=False),
sa.Column('adj_kind', sa.String(length=20), nullable=False),
sa.Column('adj_value', sa.Numeric(precision=12, scale=6), nullable=False),
sa.Column('adj_reason', sa.String(length=255), nullable=False),
sa.Column('monthly_kind', sa.String(length=10), nullable=False),
sa.Column('monthly_values', sa.JSON(), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
sa.ForeignKeyConstraint(['year_id'], ['budget_years.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cost_lines_year_id'), 'cost_lines', ['year_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_cost_lines_year_id'), table_name='cost_lines')
op.drop_table('cost_lines')
op.drop_table('budget_years')
op.drop_index(op.f('ix_audit_log_year_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
op.drop_table('audit_log')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_table('sites')
op.drop_table('categories')
# ### end Alembic commands ###
@@ -0,0 +1,74 @@
"""departments, per-department categories and budgets, approval workflows
Existing data is kept: everything created with version 1 is moved into a department called "IT".
Revision ID: 0002
Revises: 0001
"""
import sqlalchemy as sa
from alembic import op
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def _drop_unique_on(table: str, columns: list[str]) -> None:
insp = sa.inspect(op.get_bind())
for uc in insp.get_unique_constraints(table):
if uc["column_names"] == columns:
op.drop_constraint(uc["name"], table, type_="unique")
def upgrade() -> None:
op.create_table(
"departments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=120), nullable=False, unique=True),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_table(
"workflow_steps",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("department_id", sa.Integer(), sa.ForeignKey("departments.id", ondelete="CASCADE"), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("approver_mode", sa.String(length=10), nullable=False, server_default="role"),
sa.Column("approver_ids", sa.JSON(), nullable=True),
)
op.create_index("ix_workflow_steps_department_id", "workflow_steps", ["department_id"])
op.add_column("users", sa.Column("department_id", sa.Integer(), nullable=True))
op.add_column("categories", sa.Column("department_id", sa.Integer(), nullable=True))
op.add_column("budget_years", sa.Column("department_id", sa.Integer(), nullable=True))
op.add_column("budget_years", sa.Column("current_step", sa.Integer(), nullable=True))
conn = op.get_bind()
has_data = any(conn.execute(sa.text(f"SELECT 1 FROM {t} LIMIT 1")).first()
for t in ("users", "categories", "budget_years"))
if has_data:
conn.execute(sa.text("INSERT INTO departments (name, active, created_at) VALUES ('IT', true, now())"))
dept_id = conn.execute(sa.text("SELECT id FROM departments WHERE name = 'IT'")).scalar_one()
for table in ("users", "categories", "budget_years"):
conn.execute(sa.text(f"UPDATE {table} SET department_id = :d"), {"d": dept_id})
conn.execute(sa.text("UPDATE budget_years SET current_step = 0 WHERE status = 'in_review'"))
op.alter_column("categories", "department_id", nullable=False)
op.alter_column("budget_years", "department_id", nullable=False)
op.create_foreign_key("fk_users_department", "users", "departments", ["department_id"], ["id"])
op.create_foreign_key("fk_categories_department", "categories", "departments", ["department_id"], ["id"],
ondelete="CASCADE")
op.create_foreign_key("fk_budget_years_department", "budget_years", "departments", ["department_id"], ["id"])
op.create_index("ix_categories_department_id", "categories", ["department_id"])
op.create_index("ix_budget_years_department_id", "budget_years", ["department_id"])
_drop_unique_on("categories", ["name_en"])
_drop_unique_on("budget_years", ["year"])
op.create_unique_constraint("uq_categories_department_name", "categories", ["department_id", "name_en"])
op.create_unique_constraint("uq_budget_years_department_year", "budget_years", ["department_id", "year"])
def downgrade() -> None:
raise NotImplementedError("Restore a backup taken before the upgrade instead.")
@@ -0,0 +1,69 @@
"""mail account settings and the mail log
Only adds tables; existing data is untouched.
Revision ID: 0003
Revises: 0002
"""
import sqlalchemy as sa
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"mail_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("smtp_host", sa.String(length=255), nullable=False, server_default=""),
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"),
sa.Column("smtp_security", sa.String(length=10), nullable=False, server_default="starttls"),
sa.Column("smtp_username", sa.String(length=255), nullable=False, server_default=""),
sa.Column("smtp_password", sa.Text(), nullable=False, server_default=""),
sa.Column("from_address", sa.String(length=255), nullable=False, server_default=""),
sa.Column("from_name", sa.String(length=255), nullable=False, server_default=""),
sa.Column("reply_to", sa.String(length=255), nullable=False, server_default=""),
sa.Column("signature", sa.Text(), nullable=False, server_default=""),
sa.Column("save_to_sent", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("imap_host", sa.String(length=255), nullable=False, server_default=""),
sa.Column("imap_port", sa.Integer(), nullable=False, server_default="993"),
sa.Column("imap_security", sa.String(length=10), nullable=False, server_default="ssl"),
sa.Column("imap_same_login", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("imap_username", sa.String(length=255), nullable=False, server_default=""),
sa.Column("imap_password", sa.Text(), nullable=False, server_default=""),
sa.Column("sent_folder", sa.String(length=255), nullable=False, server_default=""),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
)
op.create_table(
"mail_log",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("year_id", sa.Integer(), nullable=True),
sa.Column("context", sa.String(length=255), nullable=False),
sa.Column("sender", sa.String(length=255), nullable=False),
sa.Column("recipients", sa.JSON(), nullable=False),
sa.Column("subject", sa.String(length=500), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("attachments", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error", sa.String(length=1000), nullable=False),
sa.Column("sent_copy", sa.String(length=20), nullable=False),
sa.Column("sent_copy_detail", sa.String(length=500), nullable=False),
sa.Column("message_id", sa.String(length=255), nullable=False),
)
op.create_index("ix_mail_log_at", "mail_log", ["at"])
op.create_index("ix_mail_log_year_id", "mail_log", ["year_id"])
def downgrade() -> None:
op.drop_index("ix_mail_log_year_id", "mail_log")
op.drop_index("ix_mail_log_at", "mail_log")
op.drop_table("mail_log")
op.drop_table("mail_settings")
@@ -0,0 +1,24 @@
"""cost lines can be split over selected months only
Only adds a nullable column; existing lines keep their 12-month or monthly-amount split.
Revision ID: 0004
Revises: 0003
"""
import sqlalchemy as sa
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("cost_lines", sa.Column("monthly_months", sa.JSON(), nullable=True))
def downgrade() -> None:
# lines split over selected months fall back to the 12-month split
op.execute("UPDATE cost_lines SET monthly_kind = 'even' WHERE monthly_kind = 'selected'")
op.drop_column("cost_lines", "monthly_months")
@@ -0,0 +1,42 @@
"""reopen requests, archived budget versions, source file name
Existing budgets stay as they are (not archived). The one-budget-per-department-and-year rule now only
applies to current budgets, so a replaced budget can be kept, archived, next to its replacement.
Revision ID: 0005
Revises: 0004
"""
import sqlalchemy as sa
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("budget_years", sa.Column("source_file", sa.String(length=255), nullable=False, server_default=""))
op.add_column("budget_years", sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("budget_years", sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("budget_years", sa.Column("archived_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True))
op.drop_constraint("uq_budget_years_department_year", "budget_years", type_="unique")
op.create_index("uq_budget_years_current", "budget_years", ["department_id", "year"], unique=True,
postgresql_where=sa.text("NOT archived"), sqlite_where=sa.text("NOT archived"))
op.create_table(
"reopen_requests",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("year_id", sa.Integer(), sa.ForeignKey("budget_years.id", ondelete="CASCADE"), nullable=False),
sa.Column("requested_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("decided_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decision_note", sa.Text(), nullable=False),
)
op.create_index("ix_reopen_requests_year_id", "reopen_requests", ["year_id"])
def downgrade() -> None:
raise NotImplementedError("restore the backup taken before the upgrade instead")
@@ -0,0 +1,23 @@
"""Upper Management role
No table changes: users.role already holds the new value "management". The revision exists so that
upgrading to version 6 still takes the before-upgrade backup, like every other version.
Revision ID: 0006
Revises: 0005
"""
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
# users given the new role go back to read-only viewers
from alembic import op
op.execute("UPDATE users SET role = 'viewer' WHERE role = 'management'")
@@ -0,0 +1,40 @@
"""MFA, profile picture and light/dark theme for users
Only adds columns to users; existing users keep their data, start without MFA or a picture, and follow
the computer's light/dark setting.
Revision ID: 0007
Revises: 0006
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
COLUMNS = [
sa.Column("theme", sa.String(length=10), nullable=False, server_default="system"),
sa.Column("mfa_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("mfa_secret", sa.String(length=255), nullable=True),
sa.Column("mfa_pending_secret", sa.String(length=255), nullable=True),
sa.Column("mfa_enabled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("mfa_last_step", sa.BigInteger(), nullable=True),
sa.Column("mfa_recovery", sa.JSON(), nullable=True),
sa.Column("mfa_failed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("mfa_locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("avatar", sa.LargeBinary(), nullable=True),
sa.Column("avatar_type", sa.String(length=30), nullable=True),
sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True),
]
def upgrade() -> None:
for column in COLUMNS:
op.add_column("users", column)
def downgrade() -> None:
for column in reversed(COLUMNS):
op.drop_column("users", column.name)
View File
+25
View File
@@ -0,0 +1,25 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from .models import AuditEntry, User
def _plain(value):
if isinstance(value, Decimal):
return str(value.normalize()) if value == value.to_integral() else str(value)
return value
def snapshot(obj, fields: list[str]) -> dict:
return {f: _plain(getattr(obj, f)) for f in fields}
def diff(before: dict, after: dict) -> dict:
return {k: [before.get(k), after.get(k)] for k in after if before.get(k) != after.get(k)}
def record(db: Session, user: User | None, entity: str, entity_id: int | None, action: str,
summary: str = "", changes: dict | None = None, year_id: int | None = None) -> None:
db.add(AuditEntry(user_id=user.id if user else None, entity=entity, entity_id=entity_id,
action=action, summary=summary[:500], changes=changes, year_id=year_id))
+38
View File
@@ -0,0 +1,38 @@
"""Run once at container start: creates the first admin and the default categories and sites."""
from sqlalchemy import func, select
from .config import settings
from .db import SessionLocal
from .models import Category, Department, Site, User
from .security import hash_password
from .seed import CATEGORIES, SITES
DEFAULT_DEPARTMENT = "IT"
def seed_categories(db, department: Department) -> None:
for i, (en, pt, section) in enumerate(CATEGORIES):
db.add(Category(department_id=department.id, name_en=en, name_pt=pt, section=section, sort_order=(i + 1) * 10))
def bootstrap() -> None:
with SessionLocal() as db:
if not db.scalar(select(func.count(Department.id))):
dept = Department(name=DEFAULT_DEPARTMENT)
db.add(dept)
db.flush()
seed_categories(db, dept)
if not db.scalar(select(func.count(User.id))):
db.add(User(email=settings.admin_email.lower(), name=settings.admin_name,
password_hash=hash_password(settings.admin_password), role="admin",
language=settings.default_language,
department_id=db.scalar(select(Department.id).order_by(Department.id))))
print(f"Created admin user {settings.admin_email}")
if not db.scalar(select(func.count(Site.id))):
for name in SITES:
db.add(Site(name=name))
db.commit()
if __name__ == "__main__":
bootstrap()
+163
View File
@@ -0,0 +1,163 @@
"""Amount expressions and budget arithmetic. All money math uses Decimal."""
import ast
import operator
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
MONTHS = 12
ZERO = Decimal("0")
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
class ExpressionError(ValueError):
pass
def normalize_number_text(text: str) -> str:
"""Accept '1 234,56', '1.234,56' and '1,234.56' as well as plain numbers."""
s = text.replace(" ", "").replace(" ", "").replace("€", "")
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
return s
def evaluate(expr: str | None) -> Decimal:
"""Evaluate a typed amount such as '=5512*12' or '3292.56+(12*839.67)'."""
if expr is None:
return ZERO
s = str(expr).strip()
if s.startswith("="):
s = s[1:]
if not s:
return ZERO
s = normalize_number_text(s)
if len(s) > 400:
raise ExpressionError("expression too long")
try:
tree = ast.parse(s, mode="eval")
except SyntaxError as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def walk(node: ast.AST) -> Decimal:
if isinstance(node, ast.Expression):
return walk(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
return Decimal(str(node.value))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
left, right = walk(node.left), walk(node.right)
if isinstance(node.op, ast.Div) and right == 0:
raise ExpressionError("division by zero")
return _OPS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
value = walk(node.operand)
return -value if isinstance(node.op, ast.USub) else value
raise ExpressionError(f"invalid expression: {expr}")
try:
return walk(tree)
except InvalidOperation as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def round2(value: Decimal) -> Decimal:
return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def factor_for(adj_kind: str, adj_value: Decimal, year_rate: Decimal) -> Decimal:
adj_value = Decimal(adj_value or 0)
if adj_kind == "year_rate":
return Decimal(1) + Decimal(year_rate or 0)
if adj_kind == "percent":
return Decimal(1) + adj_value / Decimal(100)
if adj_kind == "factor":
return adj_value
return Decimal(1)
@dataclass
class LineFigures:
prior: Decimal
base: Decimal
factor: Decimal
adjusted: Decimal
months: list[Decimal]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100
def line_figures(line, year_rate: Decimal) -> LineFigures:
# Each line is rounded to cents before it is summed, as the finance reports do.
prior = round2(Decimal(line.prior_actual or 0))
if line.monthly_kind == "manual":
months = [Decimal(str(v or 0)) for v in (line.monthly_values or [])][:MONTHS]
months += [ZERO] * (MONTHS - len(months))
total = sum(months, ZERO)
return LineFigures(prior, total, Decimal(1), total, months)
base = round2(Decimal(line.base_amount or 0))
factor = factor_for(line.adj_kind, line.adj_value, year_rate)
adjusted = round2(Decimal(line.base_amount or 0) * factor)
if line.monthly_kind == "selected":
return LineFigures(prior, base, factor, adjusted, selected_spread(adjusted, line.monthly_months))
return LineFigures(prior, base, factor, adjusted, even_spread(adjusted))
def even_spread(total: Decimal) -> list[Decimal]:
"""Twelve months in cents; December takes the rounding remainder so the months add up exactly."""
month = round2(total / MONTHS)
return [month] * (MONTHS - 1) + [total - month * (MONTHS - 1)]
def clean_months(months) -> list[int]:
"""Month numbers 1-12, sorted and without repeats."""
return sorted({int(m) for m in (months or []) if 1 <= int(m) <= MONTHS})
def selected_spread(total: Decimal, months) -> list[Decimal]:
"""Equal amounts in cents over the chosen months only (1 = January); the last chosen month takes the
rounding remainder. No months chosen means all twelve."""
chosen = clean_months(months) or list(range(1, MONTHS + 1))
share = round2(total / len(chosen))
out = [ZERO] * MONTHS
for m in chosen:
out[m - 1] = share
out[chosen[-1] - 1] = total - share * (len(chosen) - 1)
return out
@dataclass
class Totals:
prior: Decimal = ZERO
base: Decimal = ZERO
adjusted: Decimal = ZERO
months: list[Decimal] = field(default_factory=lambda: [ZERO] * MONTHS)
def add(self, f: LineFigures) -> None:
self.prior += f.prior
self.base += f.base
self.adjusted += f.adjusted
self.months = [a + b for a, b in zip(self.months, f.months)]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100
+16
View File
@@ -0,0 +1,16 @@
import os
class Settings:
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./budget.db")
secret_key: str = os.getenv("SECRET_KEY", "change-me")
admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com")
admin_password: str = os.getenv("ADMIN_PASSWORD", "admin")
admin_name: str = os.getenv("ADMIN_NAME", "Administrator")
default_language: str = os.getenv("DEFAULT_LANGUAGE", "pt")
cookie_secure: bool = os.getenv("COOKIE_SECURE", "false").lower() == "true"
session_hours: int = int(os.getenv("SESSION_HOURS", "12"))
organization: str = os.getenv("ORGANIZATION_NAME", "")
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings
_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+255
View File
@@ -0,0 +1,255 @@
"""Imports a workbook laid out like 'Budget para 2027.xlsx'.
Sheet1 holds one row per cost line (prior actual, base, adjusted, remarks); the 'Budget IT'
sheet says which category each row belongs to (column P = '=Sheet1!D<row>') and holds the
revenue lines with typed monthly values.
"""
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from io import BytesIO
from zoneinfo import ZoneInfo
import openpyxl
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import BudgetYear, Category, CostLine, Site, User
REF_RE = re.compile(r"^=\s*'?Sheet1'?!\$?D\$?(\d+)\s*$", re.I)
FACTOR_RE = re.compile(r"^=\s*\$?C\$?\d+\s*\*\s*([\d.]+)\s*$", re.I)
ARITH_RE = re.compile(r"^=?[\d\s.+\-*/()]+$")
HEADER_RE = re.compile(r"^(\d{4})\s+(ATUAL|ACTUAL|SEM AJUSTE|COM AJUSTE)", re.I)
# Reasons the budget owner gave for factors that differ from the year's rate.
KNOWN_REASONS = {
"hardware": "Aumento de preço do hardware / Hardware price increase",
}
class ImportErrorWithDetail(ValueError):
pass
@dataclass
class ImportResult:
year: int
lines: int = 0
categories_created: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def _sheet(wb, *names):
for ws in wb.worksheets:
if ws.title.strip().lower() in [n.lower() for n in names]:
return ws
return None
def _amount(formula_cell, value_cell, warnings: list[str], where: str) -> tuple[str, Decimal]:
"""Keep a pure-arithmetic formula as the typed expression; otherwise take Excel's cached value."""
raw = formula_cell.value
if raw is None or raw == "":
return "0", Decimal("0")
if isinstance(raw, (int, float)):
return str(raw), Decimal(str(raw))
text = str(raw).strip()
if ARITH_RE.match(text):
try:
return text.lstrip("="), evaluate(text)
except ExpressionError:
pass
cached = value_cell.value
if isinstance(cached, (int, float)):
return str(round(cached, 6)), Decimal(str(cached))
warnings.append(f"{where}: could not read '{text}', used 0")
return "0", Decimal("0")
def _category_index(db: Session, department_id: int) -> dict[str, Category]:
cats = db.scalars(select(Category).where(Category.department_id == department_id))
index = {}
for c in cats:
index[c.name_en.strip().lower()] = c
if c.name_pt:
index.setdefault(c.name_pt.strip().lower(), c)
return index
def current_year(db: Session, department_id: int, year: int) -> BudgetYear | None:
return db.scalar(select(BudgetYear).where(BudgetYear.department_id == department_id, BudgetYear.year == year,
BudgetYear.archived.is_(False)))
def archived_name(text: str, when: datetime) -> str:
"""'Orcamento_IT_2027.xlsx' -> 'Orcamento_IT_2027_2026-10-28_2250.xlsx'; titles get ' (2026-10-28 22:50)'."""
if "." in text and text.rsplit(".", 1)[1].lower() in ("xlsx", "xlsm", "xls"):
stem, ext = text.rsplit(".", 1)
return f"{stem}_{when:%Y-%m-%d_%H%M}.{ext}"
return f"{text} ({when:%Y-%m-%d %H:%M})"
def archive_year(db: Session, existing: BudgetYear, user: User | None) -> None:
"""Keep a replaced budget, renamed with the current date and time and locked against changes."""
when = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
old_title, old_file = existing.title, existing.source_file
existing.title = archived_name(existing.title or f"Budget {existing.year}", when)[:200]
if existing.source_file:
existing.source_file = archived_name(existing.source_file, when)[:255]
existing.archived, existing.archived_at = True, datetime.now(timezone.utc)
existing.archived_by_id = user.id if user else None
db.flush()
record(db, user, "year", existing.id, "archive",
f"{existing.department.name} {existing.year}: '{old_title}' → '{existing.title}'"
+ (f" ({old_file} → {existing.source_file})" if old_file else ""), year_id=existing.id)
def prepare_year(db: Session, department_id: int, year: int, rate: Decimal, replace: bool,
user: User | None = None, source_file: str = "") -> BudgetYear:
existing = current_year(db, department_id, year)
if existing and not replace:
raise ImportErrorWithDetail("year_exists")
if existing and existing.status != "draft":
raise ImportErrorWithDetail("year_not_draft")
if existing:
archive_year(db, existing, user)
by = BudgetYear(department_id=department_id, year=year, default_rate=rate, source_file=source_file[:255],
title=existing_title(existing, year), notes=existing.notes if existing else "")
db.add(by)
db.flush()
return by
def existing_title(existing: BudgetYear | None, year: int) -> str:
if existing is None:
return f"Budget {year}"
# the replacement keeps the original title, without the archive date
title = existing.title
return title[: title.rfind(" (")] if title.endswith(")") and " (" in title else title
def import_workbook(db: Session, data: bytes, user: User | None, department_id: int, year: int | None = None,
replace: bool = False, filename: str = "", check_only: bool = False) -> ImportResult:
"""Imports the file into a new budget. With check_only, only reads which year it is for."""
try:
wb_f = openpyxl.load_workbook(BytesIO(data))
wb_v = openpyxl.load_workbook(BytesIO(data), data_only=True)
except Exception as exc: # openpyxl raises several unrelated types for a bad file
raise ImportErrorWithDetail("not_an_xlsx") from exc
from .template import TEMPLATE_SHEETS, import_template
if any(ws.title in TEMPLATE_SHEETS for ws in wb_v.worksheets):
return import_template(db, wb_v, wb_f, user, department_id, year, replace, filename, check_only)
inputs_f, inputs_v = _sheet(wb_f, "Sheet1", "Input", "Inputs"), _sheet(wb_v, "Sheet1", "Input", "Inputs")
budget_f = _sheet(wb_f, "Budget IT", "Budget")
if inputs_f is None or budget_f is None:
raise ImportErrorWithDetail("missing_sheets")
budget_v = wb_v[budget_f.title]
# The target year comes from the header row ("2026 ATUAL | 2027 SEM AJUSTE | 2027 COM AJUSTE").
detected = None
for row in inputs_f.iter_rows(min_row=1, max_row=10):
for cell in row:
m = HEADER_RE.match(str(cell.value or ""))
if m and "AJUSTE" in m.group(2).upper():
detected = int(m.group(1))
target_year = year or detected
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate = inputs_v["B1"].value if isinstance(inputs_v["B1"].value, (int, float)) else 0
rate = Decimal(str(rate))
result = ImportResult(year=target_year)
if check_only:
return result
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
cats = _category_index(db, department_id)
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
sites = list(db.scalars(select(Site)))
def category(name: str) -> Category:
nonlocal next_order
key = name.strip().lower()
if key not in cats:
cat = Category(department_id=department_id,
name_en=name.strip().title() if name.isupper() else name.strip(),
section="revenue" if "revenue" in key else "cost", sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[key] = cat
result.categories_created.append(cat.name_en)
return cats[key]
def site_for(name: str):
for s in sites:
if re.search(rf"\b{re.escape(s.name)}\b", name, re.I):
return s
return None
# Walk 'Budget IT': headings, then lines that point at Sheet1 or hold typed monthly values.
current = None
order = 0
for r in range(1, budget_f.max_row + 1):
label = budget_f.cell(r, 1).value
p = budget_f.cell(r, 16).value
months_f = [budget_f.cell(r, c).value for c in range(3, 15)]
if not isinstance(label, str) or not label.strip() or label.strip().startswith("…"):
continue
label = label.strip()
if label.lower().startswith("sub total"):
current = None
continue
if p is None and all(v is None for v in months_f):
if r > 9:
current = category(label)
continue
if current is None:
continue
order += 10
m = REF_RE.match(str(p or ""))
if m:
sr = int(m.group(1))
short = inputs_f.cell(sr, 1).value
name = str(short).strip() if short else label
where = f"Sheet1 row {sr}"
prior_expr, prior = _amount(inputs_f.cell(sr, 2), inputs_v.cell(sr, 2), result.warnings, where)
base_expr, base = _amount(inputs_f.cell(sr, 3), inputs_v.cell(sr, 3), result.warnings, where)
d_formula = str(inputs_f.cell(sr, 4).value or "")
fm = FACTOR_RE.match(d_formula)
if fm:
factor = Decimal(fm.group(1))
else:
d_val = inputs_v.cell(sr, 4).value
factor = (Decimal(str(d_val)) / base) if base and isinstance(d_val, (int, float)) else Decimal(1)
if factor == 1 + rate:
adj_kind, adj_value, reason = "year_rate", Decimal(0), ""
else:
adj_kind, adj_value = "factor", factor
reason = KNOWN_REASONS.get(current.name_en.lower(), "") if factor >= 2 else ""
line = CostLine(
year_id=by.id, category_id=current.id, site_id=(s.id if (s := site_for(label + " " + name)) else None),
name=name, description=label if label != name else "",
remarks=str(inputs_f.cell(sr, 5).value or ""),
prior_expr=prior_expr, prior_actual=prior, base_expr=base_expr, base_amount=base,
adj_kind=adj_kind, adj_value=adj_value, adj_reason=reason, sort_order=order)
else:
values = []
for c in range(3, 15):
v = budget_v.cell(r, c).value
values.append(float(v) if isinstance(v, (int, float)) else 0.0)
line = CostLine(year_id=by.id, category_id=current.id, name=label, monthly_kind="manual",
monthly_values=values, adj_kind="none", sort_order=order)
db.add(line)
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines into {target_year}", year_id=by.id)
db.commit()
return result
+334
View File
@@ -0,0 +1,334 @@
"""Sending reports by email through the configured account, with a copy in its Sent folder.
SMTP sends the message. When "save to Sent" is on, the same message is then appended over IMAP to the
mailbox's Sent folder (found by its \\Sent flag, or by a usual name), so it shows up there like any
message sent from a mail program. Every attempt is written to the mail log, whatever the outcome.
"""
import base64
import hashlib
import imaplib
import re
import smtplib
import socket
import ssl
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.orm import Session
from .audit import record
from .config import settings
from .models import MailLog, MailSettings, User
TIMEOUT = 30
MAX_RECIPIENTS = 25
ADDRESS = re.compile(r"^[^@\s<>,;\"']+@[^@\s<>,;\"']+\.[^@\s<>,;\"']+$")
SENT_NAMES = ("sent", "sent items", "sent messages", "sent mail", "enviados", "itens enviados",
"mensagens enviadas", "elementos enviados", "correo enviado")
class MailError(Exception):
def __init__(self, code: str, detail: str = ""):
super().__init__(code)
self.code = code
self.detail = detail[:500]
@dataclass
class Attachment:
name: str
mime: str
data: bytes
# --- stored passwords -------------------------------------------------------------------------
def _fernet() -> Fernet:
key = hashlib.sha256(("mail-password:" + settings.secret_key).encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt(password: str) -> str:
return _fernet().encrypt(password.encode()).decode() if password else ""
def decrypt(token: str) -> str:
if not token:
return ""
try:
return _fernet().decrypt(token.encode()).decode()
except InvalidToken:
# SECRET_KEY changed since the password was saved
raise MailError("password_unreadable")
def get_settings(db: Session) -> MailSettings:
cfg = db.get(MailSettings, 1)
if cfg is None:
cfg = MailSettings(id=1)
db.add(cfg)
db.flush()
return cfg
def is_ready(cfg: MailSettings) -> bool:
return bool(cfg.enabled and cfg.smtp_host and cfg.from_address)
def clean_addresses(values: list[str] | None) -> list[str]:
out = []
for raw in values or []:
for part in re.split(r"[,;\s]+", raw or ""):
part = part.strip()
if not part:
continue
if not ADDRESS.match(part):
raise MailError("invalid_address", part)
if part.lower() not in (a.lower() for a in out):
out.append(part)
return out
# --- SMTP -------------------------------------------------------------------------------------
def _smtp(cfg: MailSettings) -> smtplib.SMTP:
context = ssl.create_default_context()
try:
if cfg.smtp_security == "ssl":
server = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT, context=context)
else:
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT)
server.ehlo()
if cfg.smtp_security == "starttls":
server.starttls(context=context)
server.ehlo()
if cfg.smtp_username:
server.login(cfg.smtp_username, decrypt(cfg.smtp_password))
return server
except MailError:
raise
except smtplib.SMTPAuthenticationError as exc:
raise MailError("smtp_auth", _smtp_text(exc))
except ssl.SSLError as exc:
raise MailError("smtp_tls", str(exc))
except smtplib.SMTPNotSupportedError as exc:
raise MailError("smtp_tls", str(exc))
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
raise MailError("smtp_connect", f"{cfg.smtp_host}:{cfg.smtp_port} — {exc}")
except smtplib.SMTPException as exc:
raise MailError("smtp_error", _smtp_text(exc))
def _smtp_text(exc: Exception) -> str:
if isinstance(exc, smtplib.SMTPResponseException):
msg = exc.smtp_error.decode(errors="replace") if isinstance(exc.smtp_error, bytes) else str(exc.smtp_error)
return f"{exc.smtp_code} {msg}"
return str(exc)
def smtp_send(cfg: MailSettings, msg: EmailMessage, recipients: list[str]) -> None:
server = _smtp(cfg)
try:
refused = server.send_message(msg, from_addr=cfg.from_address, to_addrs=recipients)
if refused:
raise MailError("smtp_recipients", ", ".join(refused))
except smtplib.SMTPRecipientsRefused as exc:
raise MailError("smtp_recipients", ", ".join(exc.recipients))
except smtplib.SMTPException as exc:
raise MailError("smtp_error", _smtp_text(exc))
except OSError as exc:
raise MailError("smtp_connect", str(exc))
finally:
try:
server.quit()
except Exception:
pass
# --- IMAP (copy in the Sent folder) -----------------------------------------------------------
def _imap(cfg: MailSettings) -> imaplib.IMAP4:
host = cfg.imap_host or cfg.smtp_host
username = cfg.smtp_username if cfg.imap_same_login else cfg.imap_username
password = cfg.smtp_password if cfg.imap_same_login else cfg.imap_password
context = ssl.create_default_context()
try:
if cfg.imap_security == "ssl":
box = imaplib.IMAP4_SSL(host, cfg.imap_port, ssl_context=context, timeout=TIMEOUT)
else:
box = imaplib.IMAP4(host, cfg.imap_port, timeout=TIMEOUT)
if cfg.imap_security == "starttls":
box.starttls(ssl_context=context)
box.login(username, decrypt(password))
return box
except MailError:
raise
except imaplib.IMAP4.error as exc:
raise MailError("imap_auth", _imap_text(exc))
except ssl.SSLError as exc:
raise MailError("imap_tls", str(exc))
except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc:
raise MailError("imap_connect", f"{host}:{cfg.imap_port} — {exc}")
def _imap_text(exc: Exception) -> str:
text = exc.args[0] if exc.args else str(exc)
return text.decode(errors="replace") if isinstance(text, bytes) else str(text)
_LIST_LINE = re.compile(r'^\((?P<flags>[^)]*)\)\s+(?P<delim>"(?:[^"\\]|\\.)*"|NIL)\s+(?P<name>.+)$')
def _unquote(text: str) -> str:
text = text.strip()
if len(text) >= 2 and text[0] == text[-1] == '"':
return re.sub(r"\\(.)", r"\1", text[1:-1])
return text
def list_folders(box: imaplib.IMAP4) -> list[tuple[str, str, set[str]]]:
"""(name, delimiter, flags) of every folder. Names stay in IMAP's own encoding for APPEND."""
typ, data = box.list()
if typ != "OK":
raise MailError("imap_error", "LIST failed")
folders = []
for item in data:
if isinstance(item, tuple): # name sent as a literal: b'(... ) "/" {12}', b'Itens Enviad'
head, literal = item
line = head.decode(errors="replace")
m = _LIST_LINE.match(re.sub(r"\{\d+\}$", '""', line))
if m:
folders.append((literal.decode(errors="replace"), _unquote(m["delim"]),
set(m["flags"].lower().split())))
continue
if not item:
continue
m = _LIST_LINE.match(item.decode(errors="replace"))
if m:
delim = "" if m["delim"] == "NIL" else _unquote(m["delim"])
folders.append((_unquote(m["name"]), delim, set(m["flags"].lower().split())))
return folders
def find_sent_folder(box: imaplib.IMAP4, configured: str) -> str:
folders = list_folders(box)
if configured:
if any(name == configured for name, _, _ in folders):
return configured
raise MailError("sent_folder_not_found", configured)
for name, _, flags in folders:
if "\\sent" in flags:
return name
for name, delim, _ in folders:
leaf = name.split(delim)[-1] if delim else name
if leaf.lower() in SENT_NAMES:
return name
raise MailError("sent_folder_not_found", ", ".join(name for name, _, _ in folders)[:400])
def _quote(name: str) -> str:
return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_copy(cfg: MailSettings, msg: EmailMessage) -> str:
box = _imap(cfg)
try:
folder = find_sent_folder(box, cfg.sent_folder.strip())
typ, data = box.append(_quote(folder), r"(\Seen)", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != "OK":
raise MailError("imap_error", _imap_text(Exception(*(data or [b"APPEND failed"]))))
return folder
except imaplib.IMAP4.error as exc:
raise MailError("imap_error", _imap_text(exc))
finally:
try:
box.logout()
except Exception:
pass
# --- the whole send ---------------------------------------------------------------------------
def build_message(cfg: MailSettings, to: list[str], cc: list[str], subject: str, body: str,
attachments: list[Attachment]) -> EmailMessage:
msg = EmailMessage()
msg["From"] = formataddr((cfg.from_name or "", cfg.from_address))
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
if cfg.reply_to:
msg["Reply-To"] = cfg.reply_to
msg["Subject"] = " ".join(subject.split())
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=cfg.from_address.split("@")[-1])
text = body.strip()
if cfg.signature.strip():
text += "\n\n-- \n" + cfg.signature.strip()
msg.set_content(text + "\n")
for a in attachments:
maintype, subtype = a.mime.split(";")[0].split("/", 1)
msg.add_attachment(a.data, maintype=maintype, subtype=subtype, filename=a.name)
return msg
def send(db: Session, user: User, cfg: MailSettings, *, to: list[str], cc: list[str], subject: str, body: str,
attachments: list[Attachment], kind: str = "report", year_id: int | None = None,
context: str = "") -> MailLog:
"""Send, save the Sent copy, and log it. Never raises for delivery problems: the log says what happened."""
log = MailLog(at=datetime.now(timezone.utc), user_id=user.id, kind=kind, year_id=year_id, context=context[:255],
sender=cfg.from_address, recipients={"to": to, "cc": cc}, subject=subject[:500], body=body,
attachments=[{"name": a.name, "size": len(a.data)} for a in attachments],
status="failed", error="", sent_copy="off", sent_copy_detail="", message_id="")
try:
msg = build_message(cfg, to, cc, subject, body, attachments)
log.message_id = msg["Message-ID"]
smtp_send(cfg, msg, to + cc)
log.status = "sent"
except MailError as exc:
log.error = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:1000]
except (ValueError, TypeError) as exc:
log.error = f"invalid_message: {exc}"[:1000]
if log.status == "sent" and cfg.save_to_sent:
try:
folder = save_copy(cfg, msg)
log.sent_copy, log.sent_copy_detail = "saved", folder[:500]
except MailError as exc:
log.sent_copy = "failed"
log.sent_copy_detail = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:500]
db.add(log)
db.flush()
where = ", ".join(to + cc)
record(db, user, "mail", log.id, "send" if log.status == "sent" else "send_failed",
f"{subject} → {where}"[:500], year_id=year_id)
db.commit()
return log
def check(cfg: MailSettings) -> dict:
"""Sign in to SMTP (and IMAP when the Sent copy is on) without sending anything."""
result = {"smtp": "ok", "imap": "off", "sent_folder": None}
try:
server = _smtp(cfg)
server.quit()
except MailError as exc:
result["smtp"] = exc.code + (f": {exc.detail}" if exc.detail else "")
if cfg.save_to_sent:
try:
box = _imap(cfg)
try:
result["sent_folder"] = find_sent_folder(box, cfg.sent_folder.strip())
result["imap"] = "ok"
finally:
try:
box.logout()
except Exception:
pass
except MailError as exc:
result["imap"] = exc.code + (f": {exc.detail}" if exc.detail else "")
return result
+25
View File
@@ -0,0 +1,25 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from . import models # noqa: F401 (registers tables)
from .routers import activity, admin, auth, mail, reports, years
app = FastAPI(title="Budget & Capex", docs_url="/api/docs", openapi_url="/api/openapi.json")
@app.middleware("http")
async def csrf_guard(request: Request, call_next):
# Cookies are SameSite=Lax; requiring a custom header on writes also blocks cross-site form posts.
if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
if request.headers.get("x-requested-with") != "budget-app":
return JSONResponse({"detail": "missing_header"}, status_code=403)
return await call_next(request)
@app.get("/api/health")
def health():
return {"ok": True}
for r in (auth.router, admin.router, years.router, reports.router, mail.router, activity.router):
app.include_router(r)
+121
View File
@@ -0,0 +1,121 @@
"""MFA with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds, SHA-1).
Works with Microsoft Authenticator, Google Authenticator, Authy, 1Password and similar apps.
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import segno
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
from .models import User
ISSUER = "Budget & Capex"
STEP = 30
WINDOW = 1 # also accept the code just before and just after, for clocks that are slightly off
MAX_FAILED = 5
LOCK_MINUTES = 5
RECOVERY_CODES = 8
def _fernet() -> Fernet:
key = hashlib.sha256(("mfa-secret:" + settings.secret_key).encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def new_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def seal(secret: str) -> str:
return _fernet().encrypt(secret.encode()).decode()
def unseal(token: str | None) -> str | None:
try:
return _fernet().decrypt(token.encode()).decode() if token else None
except InvalidToken: # SECRET_KEY changed: an administrator has to reset this user's MFA
return None
def code_at(secret: str, step: int) -> str:
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return f"{value % 1_000_000:06d}"
def matching_step(secret: str, code: str, last_step: int | None = None, at: float | None = None) -> int | None:
"""The time step the code belongs to, or None. A step already used is refused (no replay)."""
code = "".join(ch for ch in code if ch.isdigit())
if len(code) != 6:
return None
now = int((at or time.time()) // STEP)
for step in range(now - WINDOW, now + WINDOW + 1):
if hmac.compare_digest(code_at(secret, step), code) and (last_step is None or step > last_step):
return step
return None
def otpauth_uri(secret: str, email: str) -> str:
label = quote(f"{ISSUER}:{email}")
return f"otpauth://totp/{label}?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period={STEP}"
def qr_svg(uri: str) -> str:
return segno.make(uri, error="m").svg_inline(scale=5, dark="#000", light="#fff", border=2)
def _hash(code: str) -> str:
return hashlib.sha256(code.replace("-", "").strip().lower().encode()).hexdigest()
def new_recovery_codes() -> tuple[list[str], list[str]]:
"""Codes to show once, and their hashes to keep."""
alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
codes = ["".join(secrets.choice(alphabet) for _ in range(4)) + "-" + "".join(secrets.choice(alphabet) for _ in range(4))
for _ in range(RECOVERY_CODES)]
return codes, [_hash(c) for c in codes]
def locked(user: User) -> bool:
until = user.mfa_locked_until
if until and until.tzinfo is None:
until = until.replace(tzinfo=timezone.utc)
return bool(until and until > datetime.now(timezone.utc))
def check(user: User, code: str) -> str | None:
"""Checks an authenticator code or a recovery code. Returns 'totp', 'recovery' or None, and updates
the user's counters (a recovery code is used up; too many wrong codes lock MFA for a few minutes)."""
if locked(user):
return None
code = (code or "").strip()
secret = unseal(user.mfa_secret)
kind = None
if secret and (step := matching_step(secret, code, user.mfa_last_step)) is not None:
user.mfa_last_step, kind = step, "totp"
elif any(ch.isalpha() for ch in code) and _hash(code) in (user.mfa_recovery or []):
user.mfa_recovery = [h for h in user.mfa_recovery if h != _hash(code)]
kind = "recovery"
if kind:
user.mfa_failed, user.mfa_locked_until = 0, None
else:
user.mfa_failed = (user.mfa_failed or 0) + 1
if user.mfa_failed >= MAX_FAILED:
user.mfa_failed, user.mfa_locked_until = 0, datetime.now(timezone.utc) + timedelta(minutes=LOCK_MINUTES)
return kind
def disable(user: User) -> None:
user.mfa_enabled, user.mfa_secret, user.mfa_pending_secret = False, None, None
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = None, None, None
user.mfa_failed, user.mfa_locked_until = 0, None
+256
View File
@@ -0,0 +1,256 @@
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import (JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, String, Text,
UniqueConstraint, false)
from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
Money = Numeric(18, 6)
def now() -> datetime:
return datetime.now(timezone.utc)
class Department(Base):
__tablename__ = "departments"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
steps: Mapped[list["WorkflowStep"]] = relationship(
back_populates="department", cascade="all, delete-orphan", order_by="WorkflowStep.position")
class WorkflowStep(Base):
"""One approval step of a department's workflow. Steps run in `position` order after submission."""
__tablename__ = "workflow_steps"
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
position: Mapped[int] = mapped_column(Integer, default=0)
name: Mapped[str] = mapped_column(String(120))
# role: any approver (or admin-role member) of the department; users: only the listed users
approver_mode: Mapped[str] = mapped_column(String(10), default="role")
approver_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
department: Mapped[Department] = relationship(back_populates="steps")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
password_hash: Mapped[str] = mapped_column(String(255))
# admin: everything; approver: edit + approve own department; editor: edit own department; viewer: read
role: Mapped[str] = mapped_column(String(20), default="viewer")
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
language: Mapped[str] = mapped_column(String(5), default="pt")
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# light, dark, or system (follow the computer's setting)
theme: Mapped[str] = mapped_column(String(10), default="system", server_default="system")
# MFA with an authenticator app (TOTP). Secrets are stored encrypted; recovery codes as SHA-256 hashes.
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
mfa_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
mfa_pending_secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
mfa_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
mfa_last_step: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # a code works only once
mfa_recovery: Mapped[list | None] = mapped_column(JSON, nullable=True)
mfa_failed: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
mfa_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# profile picture, resized to at most 256 px
avatar: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True)
avatar_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
avatar_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
department: Mapped[Department | None] = relationship()
class Site(Base):
__tablename__ = "sites"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
class Category(Base):
__tablename__ = "categories"
__table_args__ = (UniqueConstraint("department_id", "name_en", name="uq_categories_department_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True)
name_en: Mapped[str] = mapped_column(String(200))
name_pt: Mapped[str] = mapped_column(String(200), default="")
# revenue lines are shown in the budget but left out of the cost report
section: Mapped[str] = mapped_column(String(20), default="cost")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
show_when_empty: Mapped[bool] = mapped_column(Boolean, default=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
class BudgetYear(Base):
__tablename__ = "budget_years"
# One current budget per department and year; replaced versions are archived next to it.
__table_args__ = (Index("uq_budget_years_current", "department_id", "year", unique=True,
postgresql_where=text("NOT archived"), sqlite_where=text("NOT archived")),)
id: Mapped[int] = mapped_column(primary_key=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), index=True)
year: Mapped[int] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(200), default="")
default_rate: Mapped[Decimal] = mapped_column(Numeric(9, 6), default=Decimal("0"))
# draft -> in_review (one round per workflow step) -> approved; only drafts can be edited
status: Mapped[str] = mapped_column(String(20), default="draft")
current_step: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
submitted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# the Excel file the budget was last loaded from, if any
source_file: Mapped[str] = mapped_column(String(255), default="", server_default="")
# an archived budget was replaced by an upload: kept for reference, read-only
archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
archived_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
department: Mapped[Department] = relationship()
lines: Mapped[list["CostLine"]] = relationship(back_populates="budget_year", cascade="all, delete-orphan")
submitted_by: Mapped[User | None] = relationship(foreign_keys=[submitted_by_id])
approved_by: Mapped[User | None] = relationship(foreign_keys=[approved_by_id])
archived_by: Mapped[User | None] = relationship(foreign_keys=[archived_by_id])
reopen_requests: Mapped[list["ReopenRequest"]] = relationship(
back_populates="budget_year", cascade="all, delete-orphan", order_by="ReopenRequest.id")
class CostLine(Base):
__tablename__ = "cost_lines"
id: Mapped[int] = mapped_column(primary_key=True)
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
site_id: Mapped[int | None] = mapped_column(ForeignKey("sites.id"), nullable=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(String(500), default="")
remarks: Mapped[str] = mapped_column(Text, default="")
# amounts may be typed as arithmetic, e.g. "3292.56+(12*839.67)"; the evaluated value is stored next to it
prior_expr: Mapped[str] = mapped_column(String(500), default="0")
prior_actual: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
base_expr: Mapped[str] = mapped_column(String(500), default="0")
base_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0"))
# year_rate: base * (1 + year rate); percent: base * (1 + value/100); factor: base * value; none: base
adj_kind: Mapped[str] = mapped_column(String(20), default="year_rate")
adj_value: Mapped[Decimal] = mapped_column(Numeric(12, 6), default=Decimal("0"))
adj_reason: Mapped[str] = mapped_column(String(255), default="")
# even: adjusted / 12; manual: twelve typed amounts, whose sum is the budget
monthly_kind: Mapped[str] = mapped_column(String(10), default="even")
monthly_values: Mapped[list | None] = mapped_column(JSON, nullable=True)
# monthly_kind "selected": the adjusted amount is split equally over these months (1 = January)
monthly_months: Mapped[list | None] = mapped_column(JSON, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
budget_year: Mapped[BudgetYear] = relationship(back_populates="lines")
category: Mapped[Category] = relationship()
site: Mapped[Site | None] = relationship()
class AuditEntry(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
entity: Mapped[str] = mapped_column(String(40))
entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[str] = mapped_column(String(40))
summary: Mapped[str] = mapped_column(String(500), default="")
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
user: Mapped[User | None] = relationship()
class MailSettings(Base):
"""The one mail account the app sends from (row id 1). Passwords are stored encrypted."""
__tablename__ = "mail_settings"
id: Mapped[int] = mapped_column(primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
smtp_host: Mapped[str] = mapped_column(String(255), default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_security: Mapped[str] = mapped_column(String(10), default="starttls") # starttls | ssl | none
smtp_username: Mapped[str] = mapped_column(String(255), default="")
smtp_password: Mapped[str] = mapped_column(Text, default="")
from_address: Mapped[str] = mapped_column(String(255), default="")
from_name: Mapped[str] = mapped_column(String(255), default="")
reply_to: Mapped[str] = mapped_column(String(255), default="")
signature: Mapped[str] = mapped_column(Text, default="")
# copy of every sent message in the mailbox's Sent folder, over IMAP
save_to_sent: Mapped[bool] = mapped_column(Boolean, default=True)
imap_host: Mapped[str] = mapped_column(String(255), default="")
imap_port: Mapped[int] = mapped_column(Integer, default=993)
imap_security: Mapped[str] = mapped_column(String(10), default="ssl")
imap_same_login: Mapped[bool] = mapped_column(Boolean, default=True)
imap_username: Mapped[str] = mapped_column(String(255), default="")
imap_password: Mapped[str] = mapped_column(Text, default="")
sent_folder: Mapped[str] = mapped_column(String(255), default="") # empty: find it automatically
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by: Mapped[User | None] = relationship()
class MailLog(Base):
"""One message the app tried to send: who, to whom, what, and how it went."""
__tablename__ = "mail_log"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
kind: Mapped[str] = mapped_column(String(20), default="report") # report | test
year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
context: Mapped[str] = mapped_column(String(255), default="")
sender: Mapped[str] = mapped_column(String(255), default="")
recipients: Mapped[dict] = mapped_column(JSON, default=dict) # {"to": [...], "cc": [...]}
subject: Mapped[str] = mapped_column(String(500), default="")
body: Mapped[str] = mapped_column(Text, default="")
attachments: Mapped[list] = mapped_column(JSON, default=list) # [{"name": ..., "size": ...}]
status: Mapped[str] = mapped_column(String(20), default="sent") # sent | failed
error: Mapped[str] = mapped_column(String(1000), default="")
sent_copy: Mapped[str] = mapped_column(String(20), default="off") # saved | failed | off
sent_copy_detail: Mapped[str] = mapped_column(String(500), default="")
message_id: Mapped[str] = mapped_column(String(255), default="")
user: Mapped[User | None] = relationship()
class ReopenRequest(Base):
"""A user asks for an approved budget to be reopened, saying why; an approver accepts or declines."""
__tablename__ = "reopen_requests"
id: Mapped[int] = mapped_column(primary_key=True)
year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True)
requested_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
reason: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | accepted | declined
decided_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
decision_note: Mapped[str] = mapped_column(Text, default="")
budget_year: Mapped[BudgetYear] = relationship(back_populates="reopen_requests")
requested_by: Mapped[User | None] = relationship(foreign_keys=[requested_by_id])
decided_by: Mapped[User | None] = relationship(foreign_keys=[decided_by_id])
+33
View File
@@ -0,0 +1,33 @@
"""Short emails about reopen requests, sent through the configured account when email is on.
Best effort: a failure is written to the mail log and never blocks the action itself.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import mailer
from .models import BudgetYear, MailSettings, User
def _ready(db: Session) -> MailSettings | None:
cfg = db.get(MailSettings, 1)
return cfg if cfg and mailer.is_ready(cfg) else None
def reopeners(db: Session, budget: BudgetYear) -> list[User]:
"""Who can reopen the budget: administrators and the department's approvers."""
users = db.scalars(select(User).where(User.active)).all()
return [u for u in users if u.role == "admin" or (u.role == "approver" and u.department_id == budget.department_id)]
def send(db: Session, actor: User, budget: BudgetYear, to: list[User], subject: str, body: str) -> None:
cfg = _ready(db)
addresses = sorted({u.email for u in to if u.email and u.id != actor.id})
if not cfg or not addresses:
return
try:
addresses = mailer.clean_addresses(addresses)
except mailer.MailError:
return
mailer.send(db, actor, cfg, to=addresses, cc=[], subject=subject, body=body, attachments=[], kind="notice",
year_id=budget.id, context=f"{budget.department.name} {budget.year}")
+95
View File
@@ -0,0 +1,95 @@
"""Who may do what with a department's budget.
Everyone signed in can view every budget. Changing and approving is limited to the budget's own
department, following the workflow the administrator set up for it. Upper Management only views,
downloads and emails budgets and reports, in every department.
"""
from fastapi import HTTPException
from .models import BudgetYear, Department, User, WorkflowStep
from .security import ROLES
def rank(user: User) -> int:
return ROLES.index(user.role)
def is_admin(user: User) -> bool:
return user.role == "admin"
def is_management(user: User) -> bool:
return user.role == "management"
def member(user: User, department_id: int) -> bool:
return user.department_id == department_id
def can_edit_department(user: User, department_id: int) -> bool:
"""Create, import and edit draft budgets of the department."""
return is_admin(user) or (member(user, department_id) and rank(user) >= ROLES.index("editor"))
def steps_of(department: Department) -> list[WorkflowStep]:
return sorted(department.steps, key=lambda s: (s.position, s.id))
def can_approve_step(user: User, step: WorkflowStep | None, department_id: int) -> bool:
if step is None:
# No workflow configured: the department's approvers sign off in one step.
return member(user, department_id) and rank(user) >= ROLES.index("approver")
if step.approver_mode == "users":
return user.id in (step.approver_ids or []) and not is_management(user)
return member(user, department_id) and rank(user) >= ROLES.index("approver")
def current_step(budget: BudgetYear) -> WorkflowStep | None:
steps = steps_of(budget.department)
if budget.status != "in_review" or not steps:
return None
index = min(budget.current_step or 0, len(steps) - 1)
return steps[index]
def can_reopen(user: User, budget: BudgetYear) -> bool:
return is_admin(user) or (member(user, budget.department_id) and rank(user) >= ROLES.index("approver"))
def pending_reopen(budget: BudgetYear):
return next((r for r in budget.reopen_requests if r.status == "pending"), None)
def allowed_actions(user: User, budget: BudgetYear) -> dict:
if budget.archived:
# a replaced version: read-only for everyone; administrators may still delete it
return {"edit": False, "submit": False, "approve": False, "return": False, "reopen": False,
"request_reopen": False, "decide_reopen": False, "upload": False, "delete": is_admin(user)}
dept = budget.department_id
editor = can_edit_department(user, dept)
approver = budget.status == "in_review" and can_approve_step(user, current_step(budget), dept)
return {
"edit": budget.status == "draft" and editor,
"submit": budget.status == "draft" and editor,
"approve": approver,
"return": approver or (budget.status == "in_review" and is_admin(user)),
"reopen": budget.status == "approved" and can_reopen(user, budget),
# anyone may ask for an approved budget to be reopened (approvers reopen it straight away)
"request_reopen": budget.status == "approved" and pending_reopen(budget) is None and not is_management(user),
"decide_reopen": budget.status == "approved" and pending_reopen(budget) is not None and can_reopen(user, budget),
"upload": budget.status == "draft" and editor,
# Deleting a budget removes it and its reports for everyone, so only administrators may, in any status.
"delete": is_admin(user),
}
def require_action(user: User, budget: BudgetYear, action: str) -> None:
if not allowed_actions(user, budget)[action]:
if action == "edit" and budget.status != "draft":
raise HTTPException(409, "year_not_draft")
raise HTTPException(403, "forbidden")
def require_department_editor(user: User, department_id: int) -> None:
if not can_edit_department(user, department_id):
raise HTTPException(403, "forbidden")
+59
View File
@@ -0,0 +1,59 @@
"""Run before `alembic upgrade`: when there are database changes to apply, ask the backup container for a
fresh dump and wait for it, so every upgrade can be rolled back to the moment just before it.
The api stops (and Docker retries it) if the backup fails, unless SKIP_PRE_UPGRADE_BACKUP=true.
"""
import os
import sys
import time
import uuid
from pathlib import Path
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from .db import engine
REQUESTS = Path(os.getenv("BACKUP_REQUEST_DIR", "/requests"))
TIMEOUT = int(os.getenv("PRE_UPGRADE_BACKUP_TIMEOUT", "900"))
def pending() -> tuple[str | None, str]:
head = ScriptDirectory.from_config(Config("alembic.ini")).get_current_head()
with engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
return current, head
def main() -> int:
current, head = pending()
if current is None or current == head:
return 0 # new database, or nothing to migrate
if os.getenv("SKIP_PRE_UPGRADE_BACKUP", "").lower() == "true":
print(f"pre-upgrade backup skipped ({current} -> {head})", flush=True)
return 0
if not REQUESTS.is_dir():
print(f"pre-upgrade backup: {REQUESTS} not mounted, cannot ask the backup container", file=sys.stderr, flush=True)
return 1
rid = time.strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6]
(REQUESTS / f"{rid}.request").write_text(f"database upgrade {current} -> {head}\n")
print(f"database upgrade {current} -> {head}: waiting for the backup container to take a backup", flush=True)
done = REQUESTS / f"{rid}.done"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
if done.exists():
answer = done.read_text().strip() # the backup container clears old answers itself
if answer == "ok":
print("pre-upgrade backup written", flush=True)
return 0
print("pre-upgrade backup FAILED; not upgrading. See: docker compose logs backup", file=sys.stderr, flush=True)
return 1
time.sleep(2)
print("pre-upgrade backup did not answer in time; not upgrading. Is the backup container running?",
file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint."""
import math
from html import escape
BLUE = "#2f6fd6"
ORANGE = "#e8632b"
INK = "#1d1d1f"
MUTED = "#6b6b70"
GRID = "#e4e4e8"
def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]:
if hi <= lo:
hi = lo + 1
raw = (hi - lo) / count
mag = 10 ** math.floor(math.log10(raw))
step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw)
start = math.floor(lo / step) * step
ticks, v = [], start
while v <= hi + step * 0.001:
ticks.append(round(v, 10))
v += step
if ticks[-1] < hi:
ticks.append(ticks[-1] + step)
return ticks
def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]:
words, out, cur = text.split(), [], ""
for w in words:
if len(cur) + len(w) + (1 if cur else 0) <= width:
cur = f"{cur} {w}".strip()
else:
out.append(cur)
cur = w
out.append(cur)
out = [o for o in out if o]
if len(out) > lines:
out = out[:lines]
out[-1] = out[-1][: width - 1].rstrip() + "…"
return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""]
def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900,
height: int = 490) -> str:
"""Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height."""
if not rows:
return ""
label_w = 250
top, bottom = 8, 30
row_h = min(130.0, (height - top - bottom) / len(rows))
bar_h = min(row_h * 0.34, 36)
plot_w = width - label_w - (80 if fmt_value else 24)
height = round(top + bottom + row_h * len(rows))
hi = max(max(p, a) for _, p, a in rows) or 1
ticks = nice_ticks(0, hi, 8)
x = lambda v: label_w + plot_w * v / ticks[-1]
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
for i, (label, prior, adjusted) in enumerate(rows):
y0 = top + i * row_h
cy = y0 + row_h / 2
fs = 13 if row_h >= 22 else 11.5
wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1)
for j, chunk in enumerate(wrapped):
dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3
parts.append(f'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))):
by = cy - bar_h - 1 + k * (bar_h + 2)
w = max(x(max(value, 0)) - label_w, 0)
parts.append(f'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
if fmt_value and row_h >= 26:
vfs = min(12, bar_h + 1)
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str:
"""Ranked % change per category; increases orange, decreases blue."""
if not rows:
return ""
label_w, top, bottom = 270, 8, 30
row_h = min(60.0, (height - top - bottom) / len(rows))
bar = min(row_h * 0.6, 28)
plot_w = width - label_w - 90
height = round(top + bottom + row_h * len(rows))
lo = min(0.0, min(v for _, v in rows))
hi = max(0.0, max(v for _, v in rows)) or 1
ticks = nice_ticks(lo, hi, 8)
span = ticks[-1] - ticks[0]
x = lambda v: label_w + plot_w * (v - ticks[0]) / span
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
for i, (label, value) in enumerate(rows):
cy = top + i * row_h + row_h / 2
x0, x1 = sorted((x(0), x(value)))
colour = ORANGE if value > 0 else BLUE
parts.append(f'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
+196
View File
@@ -0,0 +1,196 @@
"""Excel exports with live formulas, so totals keep working when people edit the file."""
import re
from datetime import datetime
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from ..summary import YearView
from .i18n import T
MONEY = '#,##0.00;[Red]-#,##0.00'
PCT = '0.00%;[Red]-0.00%'
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
CAT_FILL = PatternFill("solid", fgColor="EEF2F8")
WHITE_BOLD = Font(bold=True, color="FFFFFF")
BOLD = Font(bold=True)
THIN_TOP = Border(top=Side(style="thin", color="999999"))
def _safe_sheet_name(name: str, used: set[str]) -> str:
base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet"
candidate, n = base, 2
while candidate.lower() in used:
suffix = f" ({n})"
candidate, n = base[: 31 - len(suffix)] + suffix, n + 1
used.add(candidate.lower())
return candidate
def _header(ws, row: int, labels: list[str]) -> None:
for i, label in enumerate(labels, start=1):
c = ws.cell(row, i, label)
c.font, c.fill = WHITE_BOLD, HEAD_FILL
c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center")
def _title(ws, title: str, sub: str) -> None:
ws["A1"] = title
ws["A1"].font = Font(bold=True, size=14)
ws["A2"] = sub
ws["A2"].font = Font(color="666666")
def _out(wb: Workbook) -> bytes:
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _meta(view: YearView, t: T, site: str | None) -> str:
bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"]
if site:
bits.insert(0, t("site_filter", site=site))
return " · ".join(bits)
def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
ws.title = _safe_sheet_name(t("budget_title"), set())
_title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site))
months = t("months")
_header(ws, 4, [t("line"), t("site"), *months, t("total")])
row = 5
cost_subtotals = []
cats = sorted([c for c in view.categories if c.lines or include_empty],
key=lambda c: 0 if c.category.section == "revenue" else 1)
for cv in cats:
ws.cell(row, 1, cv.name(lang)).font = BOLD
for col in range(1, 16):
ws.cell(row, col).fill = CAT_FILL
row += 1
first = row
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group"))
for m, value in enumerate(lv.figures.months):
ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY
ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY
row += 1
label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang))
ws.cell(row, 1, label).font = BOLD
for col in range(3, 16):
letter = get_column_letter(col)
formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0
c = ws.cell(row, col, formula)
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
if cv.category.section != "revenue":
cost_subtotals.append(row)
row += 2
ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD
for col in range(1, 16):
ws.cell(row, col).fill = HEAD_FILL
for col in range(3, 16):
letter = get_column_letter(col)
formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0
c = ws.cell(row, col, formula)
c.number_format, c.font = MONEY, WHITE_BOLD
ws.column_dimensions["A"].width = 52
ws.column_dimensions["B"].width = 12
for col in range(3, 16):
ws.column_dimensions[get_column_letter(col)].width = 13
ws.freeze_panes = "C5"
_inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site)
return _out(wb)
def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None:
"""The Sheet1 layout: actual, base, factor, adjusted, change, remarks."""
_title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site))
_header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"),
t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")])
row = 5
for cv in view.with_lines():
for lv in cv.lines:
f, line = lv.figures, lv.line
ws.cell(row, 1, cv.name(t.lang))
ws.cell(row, 2, line.name)
ws.cell(row, 3, line.site.name if line.site else t("group"))
ws.cell(row, 4, float(f.prior)).number_format = MONEY
ws.cell(row, 5, float(f.base)).number_format = MONEY
ws.cell(row, 6, float(f.factor)).number_format = "0.0000"
ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY
ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY
ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT
ws.cell(row, 10, line.adj_reason)
ws.cell(row, 11, line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})')
c.number_format, c.font = PCT, BOLD
for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)):
ws.column_dimensions[letter].width = width
ws.freeze_panes = "C5"
def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
used: set[str] = set()
ws.title = _safe_sheet_name(t("summary"), used)
_title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site))
_header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")])
cats = view.with_lines("cost")
row = 5
for cv in cats:
sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used))
total_row = _category_sheet(sheet, cv, t)
ref = f"'{sheet.title}'"
ws.cell(row, 1, cv.name(lang))
ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY
ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD
for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)):
ws.column_dimensions[letter].width = width
return _out(wb)
def _category_sheet(ws, cv, t: T) -> int:
_title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines)))
_header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")])
row = 5
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY
ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group"))
ws.cell(row, 7, lv.line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)):
ws.column_dimensions[letter].width = width
return row
+136
View File
@@ -0,0 +1,136 @@
from decimal import Decimal
LABELS = {
"en": {
"cost_report_title": "Capex and Costs {year}",
"cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment",
"total_across": "Total across {n} categories",
"by_category": "Budget by category",
"by_category_sub": "Total cost per category, {prior} vs. {year}",
"change_by_category": "Change by category",
"change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment",
"change_legend": "Orange = cost increase · Blue = cost reduction",
"prior_actual": "{prior} actual",
"adjusted": "{year} with adjustment",
"base": "{year} without adjustment",
"change_eur": "Change (€)",
"change_pct": "Change (%)",
"cost_lines": "{n} cost lines",
"cost_line": "1 cost line",
"summary": "Summary",
"summary_sub": "All categories, {prior} actual vs. {year} with adjustment",
"category": "Category",
"line": "Line",
"site": "Site",
"group": "Group",
"total": "Total",
"factor": "Factor",
"reason": "Reason",
"remarks": "Remarks",
"budget_title": "Budget {year} · {dept}",
"budget_sub": "Monthly budget by category",
"revenue_total": "Sub total revenue",
"sub_total": "Sub total {name}",
"total_costs": "Total costs",
"status": "Status",
"exported": "Exported",
"draft": "Draft",
"in_review": "In review",
"approved": "Approved",
"inputs": "Inputs",
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"no_data": "No cost lines yet.",
"site_filter": "Site: {site}",
},
"pt": {
"cost_report_title": "Capex e Custos para {year}",
"cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste",
"total_across": "Total de {n} categorias",
"by_category": "Orçamento por categoria",
"by_category_sub": "Custo total por categoria, {prior} vs. {year}",
"change_by_category": "Variação por categoria",
"change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste",
"change_legend": "Laranja = aumento de custo · Azul = redução de custo",
"prior_actual": "{prior} real",
"adjusted": "{year} com ajuste",
"base": "{year} sem ajuste",
"change_eur": "Variação (€)",
"change_pct": "Variação (%)",
"cost_lines": "{n} linhas de custo",
"cost_line": "1 linha de custo",
"summary": "Resumo",
"summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste",
"category": "Categoria",
"line": "Linha",
"site": "Unidade",
"group": "Grupo",
"total": "Total",
"factor": "Fator",
"reason": "Motivo",
"remarks": "Observações",
"budget_title": "Orçamento {year} · {dept}",
"budget_sub": "Orçamento mensal por categoria",
"revenue_total": "Subtotal receitas",
"sub_total": "Subtotal {name}",
"total_costs": "Total de custos",
"status": "Estado",
"exported": "Exportado",
"draft": "Rascunho",
"in_review": "Em revisão",
"approved": "Aprovado",
"inputs": "Dados",
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"no_data": "Ainda não há linhas de custo.",
"site_filter": "Unidade: {site}",
},
}
class T:
def __init__(self, lang: str, year: int, dept: str = ""):
self.lang = lang if lang in LABELS else "pt"
self.labels = LABELS[self.lang]
self.ctx = {"year": year, "prior": year - 1, "dept": dept}
def __call__(self, key: str, **kw):
value = self.labels[key]
return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value
def _group(self, value: Decimal, decimals: int) -> str:
q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}"
whole, _, frac = text.partition(".")
whole = whole.replace(",", " ")
if not frac:
return whole
return whole + ("," if self.lang == "pt" else ".") + frac
def eur(self, value, decimals: int = 2, signed: bool = False) -> str:
value = Decimal(value or 0)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "")
body = self._group(value, decimals)
return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}"
def num(self, value, decimals: int = 2) -> str:
value = Decimal(value or 0)
return ("\u2212" if value < 0 else "") + self._group(value, decimals)
def pct(self, value, signed: bool = True) -> str:
if value is None:
return "—"
value = Decimal(value)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ""
return f"{sign}{self._group(value, 2)}%"
def short_eur(self, value: float) -> str:
v = abs(value)
sign = "−" if value < 0 else ""
if v >= 1_000_000:
body = f"{v / 1_000_000:g}M"
elif v >= 1000:
body = f"{v / 1000:g}k"
else:
body = f"{v:g}"
if self.lang == "pt":
return f"{sign}{body.replace('.', ',')} €"
return f"{sign}€{body}"
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from jinja2 import Environment, FileSystemLoader, select_autoescape
from ..summary import YearView
from .charts import change_bars, grouped_bars
from .i18n import T
TEMPLATES = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
CSS = (TEMPLATES / "report.css").read_text()
def _exported() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str:
t = T(lang, view.year.year, view.year.department.name)
cost_cats = view.with_lines("cost")
cats = []
for cv in cost_cats:
rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines]
cats.append(SimpleNamespace(
name=cv.name(lang), count=len(cv.lines), totals=cv.totals,
chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0))))
overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur)
ranked = sorted([c for c in cats if c.totals.change_pct is not None],
key=lambda c: c.totals.change_pct, reverse=True)
change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v))
return env.get_template("cost_report.html").render(
t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs,
overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen)
def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str:
t = T(lang, view.year.year, view.year.department.name)
cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals)
for cv in view.categories if cv.lines or include_empty]
# revenue first, as in the Budget IT sheet
cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1)
return env.get_template("budget.html").render(
t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs,
exported=_exported(), css=CSS)
def to_pdf(html: str) -> bytes:
from weasyprint import HTML # imported lazily: it loads system libraries
return HTML(string=html).write_pdf()
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style></head>
<body>
<section class="page budget">
<h1>{{ title }}</h1>
<div class="sub">{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
<table class="grid">
<thead><tr><th>{{ t('line') }}</th><th>{{ t('site') }}</th>{% for m in t('months') %}<th>{{ m }}</th>{% endfor %}<th>{{ t("total") }} (€)</th></tr></thead>
<tbody>
{% for c in cats %}
<tr class="cat"><td colspan="15">{{ c.name }}</td></tr>
{% for l in c.lines %}
<tr><td class="name">{{ l.line.name }}</td><td>{{ l.line.site.name if l.line.site else t('group') }}</td>
{% for m in l.figures.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(l.figures.adjusted) }}</td></tr>
{% endfor %}
<tr class="subtotal"><td colspan="2">{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}</td>
{% for m in c.totals.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(c.totals.adjusted) }}</td></tr>
{% endfor %}
<tr class="grand"><td colspan="2">{{ t('total_costs') }}</td>{% for m in costs.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(costs.adjusted) }}</td></tr>
</tbody>
</table>
</section>
</body></html>
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style>
{% if screen %}<script>
function fit(){var w=document.documentElement.clientWidth-32;var z=Math.min(1,w/1280);document.documentElement.style.setProperty('--zoom',z);}
window.addEventListener('resize',fit);document.addEventListener('DOMContentLoaded',fit);
</script>{% endif %}
</head>
<body>
{% set total_pages = 3 + cats|length %}
<section class="page cover">
<h1>{{ title }}</h1>
<p class="sub">{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}</p>
<div class="kicker">{{ t('total_across', n=cats|length) }}</div>
<div class="big">{{ t.eur(costs.prior, 0) }} &nbsp;→&nbsp; {{ t.eur(costs.adjusted, 0) }}</div>
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} &nbsp;({{ t.eur(costs.change, 0, true) }})</div>
<div class="meta">{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
</section>
<section class="page">
<h1>{{ t('by_category') }}</h1>
<p class="sub">{{ t('by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="chartbox full">{{ overview_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">2 / {{ total_pages }}</span></div>
</section>
<section class="page">
<h1>{{ t('change_by_category') }}</h1>
<p class="sub">{{ t('change_by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend" style="text-align:center">{{ t('change_legend') }}</div>
<div class="chartbox full">{{ change_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">3 / {{ total_pages }}</span></div>
</section>
{% for c in cats %}
<section class="page">
<h1>{{ c.name }}</h1>
<p class="sub">{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="body">
<div class="chartbox">{{ c.chart|safe }}</div>
<div class="kpis">
<div class="kpi"><div class="k">{{ t('prior_actual') }}</div><div class="v">{{ t.eur(c.totals.prior) }}</div></div>
<div class="kpi"><div class="k">{{ t('adjusted') }}</div><div class="v">{{ t.eur(c.totals.adjusted) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_eur') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_pct') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</div></div>
</div>
</div>
<div class="footer">{{ title }}<span class="n">{{ 3 + loop.index }} / {{ total_pages }}</span></div>
</section>
{% endfor %}
<section class="page">
<h1>{{ t('summary') }}</h1>
<p class="sub">{{ t('summary_sub') }}</p>
<div class="rule"></div>
<table class="summary">
<thead><tr><th>{{ t('category') }}</th><th class="num">{{ t('prior_actual') }}</th><th class="num">{{ t('adjusted') }}</th><th class="num">{{ t('change_eur') }}</th><th class="num">{{ t('change_pct') }}</th></tr></thead>
<tbody>
{% for c in cats %}
<tr><td>{{ c.name }}</td><td class="num">{{ t.eur(c.totals.prior) }}</td><td class="num">{{ t.eur(c.totals.adjusted) }}</td>
<td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</td><td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</td></tr>
{% endfor %}
<tr class="total"><td>{{ t('total') }}</td><td class="num">{{ t.eur(costs.prior) }}</td><td class="num">{{ t.eur(costs.adjusted) }}</td>
<td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.eur(costs.change, 2, true) }}</td><td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }}</td></tr>
</tbody>
</table>
<div class="footer">{{ title }}<span class="n">{{ total_pages }} / {{ total_pages }}</span></div>
</section>
</body></html>
@@ -0,0 +1,62 @@
@page { size: 338.67mm 190.5mm; margin: 0; }
@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm;
@bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; }
@bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; }
.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9;
overflow: hidden; page-break-after: always; break-after: page; }
.page:last-child { page-break-after: auto; break-after: auto; }
.page h1 { font-size: 22pt; margin: 0 0 1.5mm; }
.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; }
.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; }
.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; }
.legend span { display: inline-block; margin-right: 18mm; }
.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; }
.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; }
.footer .n { float: right; }
.cover { padding-left: 22mm; }
.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; }
.cover h1 { font-size: 34pt; margin-top: 48mm; }
.cover .sub { font-size: 13pt; }
.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; }
.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; }
.cover .delta { font-size: 13pt; font-weight: bold; }
.up { color: #c2410c; } .down { color: #15803d; }
.body { display: flex; gap: 8mm; }
.chartbox { flex: 1 1 auto; height: 128mm; }
.chartbox.full { height: 136mm; }
.chartbox svg { width: 100%; height: 100%; }
.kpis { flex: 0 0 72mm; padding-top: 2mm; }
.kpi { margin-bottom: 7mm; }
.kpi .k { font-size: 9pt; color: #333; }
.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; }
table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; }
table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; }
table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; }
table.summary .num { text-align: right; white-space: nowrap; }
table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; }
.meta { font-size: 8pt; color: #777; margin-top: 2mm; }
/* annual budget */
.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; }
.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); }
.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; }
table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; }
table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; }
table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; }
table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; }
table.grid thead { display: table-header-group; }
table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; }
table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; }
table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; }
table.grid tr { page-break-inside: avoid; }
table.grid td.name { max-width: 70mm; }
@media screen {
body { padding: 16px 0; }
.page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); }
.budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; }
}
@@ -0,0 +1,76 @@
"""The activity log: everything users did, newest first (administrators)."""
import csv
import io
from fastapi import APIRouter, Depends
from fastapi.responses import Response
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import AuditEntry, BudgetYear, Department, User
from ..security import require
from .mail import _date
router = APIRouter(prefix="/api", tags=["activity"])
def _query(q, user_id, entity, action, year_id, date_from, date_to):
query = select(AuditEntry)
if user_id:
query = query.where(AuditEntry.user_id == user_id)
if entity:
query = query.where(AuditEntry.entity == entity)
if action:
query = query.where(AuditEntry.action == action)
if year_id:
query = query.where(AuditEntry.year_id == year_id)
if date_from:
query = query.where(AuditEntry.at >= _date(date_from))
if date_to:
query = query.where(AuditEntry.at < _date(date_to, end=True))
if q:
like = f"%{q.strip().lower()}%"
query = query.where(or_(func.lower(AuditEntry.summary).like(like),
AuditEntry.user_id.in_(select(User.id).where(func.lower(User.name).like(like)))))
return query
def _rows(db: Session, entries: list[AuditEntry]) -> list[dict]:
year_ids = {a.year_id for a in entries if a.year_id}
labels = {y.id: f"{d.name} {y.year}" for y, d in db.execute(
select(BudgetYear, Department).join(Department).where(BudgetYear.id.in_(year_ids)))} if year_ids else {}
return [{"id": a.id, "at": a.at.isoformat(), "user": a.user.name if a.user else None, "user_id": a.user_id,
"entity": a.entity, "entity_id": a.entity_id, "action": a.action, "summary": a.summary,
"changes": a.changes, "year_id": a.year_id,
"budget": labels.get(a.year_id) if a.year_id else None,
"budget_exists": a.year_id in labels} for a in entries]
@router.get("/activity")
def activity(q: str | None = None, user_id: int | None = None, entity: str | None = None, action: str | None = None,
year_id: int | None = None, date_from: str | None = None, date_to: str | None = None,
limit: int = 100, offset: int = 0, db: Session = Depends(get_db), _: User = Depends(require("admin"))):
query = _query(q, user_id, entity, action, year_id, date_from, date_to)
total = db.scalar(select(func.count()).select_from(query.subquery()))
entries = db.scalars(query.order_by(AuditEntry.at.desc(), AuditEntry.id.desc())
.limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all()
kinds = db.execute(select(AuditEntry.entity, AuditEntry.action).distinct()).all()
return {"total": total, "rows": _rows(db, entries),
"entities": sorted({e for e, _ in kinds}), "actions": sorted({a for _, a in kinds})}
@router.get("/activity.csv")
def activity_csv(q: str | None = None, user_id: int | None = None, entity: str | None = None,
action: str | None = None, year_id: int | None = None, date_from: str | None = None,
date_to: str | None = None, db: Session = Depends(get_db), _: User = Depends(require("admin"))):
entries = db.scalars(_query(q, user_id, entity, action, year_id, date_from, date_to)
.order_by(AuditEntry.at.desc(), AuditEntry.id.desc())).all()
out = io.StringIO()
w = csv.writer(out, delimiter=";")
w.writerow(["date", "user", "area", "action", "budget", "summary", "changes"])
for r in _rows(db, entries):
w.writerow([r["at"], r["user"] or "", r["entity"], r["action"], r["budget"] or "", r["summary"],
"" if not r["changes"] else str(r["changes"])])
return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="activity_log.csv"'})
+398
View File
@@ -0,0 +1,398 @@
"""Departments and their workflows, users, sites and categories.
Reading is open to every signed-in user (except the user list). Departments, workflows, users and
sites are managed by administrators; a department's categories also by that department's approvers.
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import mfa
from ..audit import diff, record, snapshot
from ..bootstrap import seed_categories
from ..db import get_db
from ..models import BudgetYear, Category, CostLine, Department, Site, User, WorkflowStep
from ..permissions import is_admin, member
from ..security import ROLES, current_user, hash_password, require
from ..serializers import category_out, department_out, site_out, user_out
from ..summary import build_year_view
from ..template import build_template
router = APIRouter(prefix="/api", tags=["admin"])
class UserIn(BaseModel):
department_id: int | None = None
email: str | None = None
name: str | None = None
role: str | None = None
language: str | None = None
active: bool | None = None
password: str | None = None
@router.get("/users")
def list_users(db: Session = Depends(get_db), _: User = Depends(require("admin"))):
return [user_out(u) for u in db.scalars(select(User).order_by(User.name))]
def _apply_user(u: User, body: UserIn, db: Session):
if "department_id" in body.model_fields_set:
if body.department_id is not None and not db.get(Department, body.department_id):
raise HTTPException(400, "invalid_department")
u.department_id = body.department_id
if body.email is not None:
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(400, "invalid_email")
clash = db.scalar(select(User).where(func.lower(User.email) == email, User.id != (u.id or 0)))
if clash:
raise HTTPException(400, "email_taken")
u.email = email
if body.name is not None:
u.name = body.name.strip()
if body.role is not None:
if body.role not in ROLES:
raise HTTPException(400, "invalid_role")
u.role = body.role
if body.language in ("pt", "en"):
u.language = body.language
if body.active is not None:
u.active = body.active
if body.password:
if len(body.password) < 8:
raise HTTPException(400, "password_too_short")
u.password_hash = hash_password(body.password)
@router.post("/users")
def create_user(body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
if not body.email or not body.name or not body.password:
raise HTTPException(400, "missing_fields")
u = User(role="viewer", language="pt", active=True, password_hash="")
_apply_user(u, body, db)
db.add(u)
db.flush()
record(db, admin, "user", u.id, "create", f"{u.name} <{u.email}> as {u.role}")
db.commit()
return user_out(u)
@router.put("/users/{user_id}")
def update_user(user_id: int, body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
u = db.get(User, user_id)
if not u:
raise HTTPException(404, "not_found")
if u.id == admin.id and (body.role not in (None, "admin") or body.active is False):
raise HTTPException(400, "cannot_demote_self")
fields = ["email", "name", "role", "active", "department_id"]
before = snapshot(u, fields)
_apply_user(u, body, db)
changes = diff(before, snapshot(u, fields))
if body.password:
changes["password"] = ["", "reset"]
record(db, admin, "user", u.id, "update", u.name, changes)
db.commit()
return user_out(u)
@router.post("/users/{user_id}/mfa/reset")
def reset_user_mfa(user_id: int, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
"""For a user who lost their phone: MFA goes off and they can set it up again after signing in."""
u = db.get(User, user_id)
if not u:
raise HTTPException(404, "not_found")
mfa.disable(u)
record(db, admin, "user", u.id, "mfa_reset", u.name)
db.commit()
return user_out(u)
@router.get("/users/{user_id}/avatar")
def user_avatar(user_id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
u = db.get(User, user_id)
if not u or not u.avatar:
raise HTTPException(404, "not_found")
return Response(u.avatar, media_type=u.avatar_type or "image/jpeg",
headers={"Cache-Control": "private, max-age=31536000, immutable"})
class SiteIn(BaseModel):
name: str | None = Field(default=None, max_length=120)
active: bool | None = None
@router.get("/sites")
def list_sites(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [site_out(s) for s in db.scalars(select(Site).order_by(Site.name))]
@router.post("/sites")
def create_site(body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
if not body.name or not body.name.strip():
raise HTTPException(400, "missing_fields")
if db.scalar(select(Site).where(func.lower(Site.name) == body.name.strip().lower())):
raise HTTPException(400, "name_taken")
s = Site(name=body.name.strip(), active=True)
db.add(s)
db.flush()
record(db, admin, "site", s.id, "create", s.name)
db.commit()
return site_out(s)
@router.put("/sites/{site_id}")
def update_site(site_id: int, body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
s = db.get(Site, site_id)
if not s:
raise HTTPException(404, "not_found")
if body.name:
s.name = body.name.strip()
if body.active is not None:
s.active = body.active
record(db, admin, "site", s.id, "update", s.name)
db.commit()
return site_out(s)
class CategoryIn(BaseModel):
department_id: int | None = None
name_en: str | None = Field(default=None, max_length=200)
name_pt: str | None = Field(default=None, max_length=200)
section: str | None = None
sort_order: int | None = None
show_when_empty: bool | None = None
active: bool | None = None
def can_manage_categories(user: User, department_id: int) -> bool:
return is_admin(user) or (member(user, department_id) and ROLES.index(user.role) >= ROLES.index("approver"))
def _require_categories(user: User, department_id: int) -> None:
if not can_manage_categories(user, department_id):
raise HTTPException(403, "forbidden")
@router.get("/categories")
def list_categories(department_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(current_user)):
q = select(Category).order_by(Category.department_id, Category.sort_order, Category.id)
if department_id:
q = q.where(Category.department_id == department_id)
return [category_out(c) for c in db.scalars(q)]
def _apply_category(c: Category, body: CategoryIn, db: Session):
if body.name_en is not None:
name = body.name_en.strip()
if not name:
raise HTTPException(400, "missing_fields")
if db.scalar(select(Category).where(Category.department_id == c.department_id,
func.lower(Category.name_en) == name.lower(), Category.id != (c.id or 0))):
raise HTTPException(400, "name_taken")
c.name_en = name
if body.name_pt is not None:
c.name_pt = body.name_pt.strip()
if body.section is not None:
if body.section not in ("cost", "revenue"):
raise HTTPException(400, "invalid_section")
c.section = body.section
for f in ("sort_order", "show_when_empty", "active"):
if getattr(body, f) is not None:
setattr(c, f, getattr(body, f))
@router.post("/categories")
def create_category(body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)):
if not body.name_en or not body.department_id or not db.get(Department, body.department_id):
raise HTTPException(400, "missing_fields")
_require_categories(admin, body.department_id)
top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == body.department_id))
c = Category(department_id=body.department_id, section="cost", name_pt="", show_when_empty=True, active=True,
sort_order=(top or 0) + 10)
_apply_category(c, body, db)
db.add(c)
db.flush()
record(db, admin, "category", c.id, "create", c.name_en)
db.commit()
return category_out(c)
@router.put("/categories/{cat_id}")
def update_category(cat_id: int, body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)):
c = db.get(Category, cat_id)
if not c:
raise HTTPException(404, "not_found")
_require_categories(admin, c.department_id)
_apply_category(c, body, db)
record(db, admin, "category", c.id, "update", c.name_en)
db.commit()
return category_out(c)
@router.delete("/categories/{cat_id}")
def delete_category(cat_id: int, db: Session = Depends(get_db), admin: User = Depends(current_user)):
c = db.get(Category, cat_id)
if not c:
raise HTTPException(404, "not_found")
_require_categories(admin, c.department_id)
if db.scalar(select(func.count(CostLine.id)).where(CostLine.category_id == cat_id)):
raise HTTPException(400, "category_in_use")
record(db, admin, "category", c.id, "delete", c.name_en)
db.delete(c)
db.commit()
return {"ok": True}
class DepartmentIn(BaseModel):
name: str | None = Field(default=None, max_length=120)
active: bool | None = None
# where the new department's categories come from: "defaults", "none" or another department's id
categories_from: str | int | None = "defaults"
@router.get("/departments")
def list_departments(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [department_out(d) for d in db.scalars(select(Department).order_by(Department.name))]
def _copy_categories(db: Session, target: Department, source) -> None:
if source in (None, "none", ""):
return
if source == "defaults":
seed_categories(db, target)
return
src = db.get(Department, int(source))
if not src:
raise HTTPException(400, "invalid_department")
existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == target.id))}
for c in db.scalars(select(Category).where(Category.department_id == src.id).order_by(Category.sort_order)):
if c.name_en.lower() not in existing:
db.add(Category(department_id=target.id, name_en=c.name_en, name_pt=c.name_pt, section=c.section,
sort_order=c.sort_order, show_when_empty=c.show_when_empty, active=c.active))
@router.post("/departments")
def create_department(body: DepartmentIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
name = (body.name or "").strip()
if not name:
raise HTTPException(400, "missing_fields")
if db.scalar(select(Department).where(func.lower(Department.name) == name.lower())):
raise HTTPException(400, "name_taken")
d = Department(name=name, active=True)
db.add(d)
db.flush()
_copy_categories(db, d, body.categories_from)
record(db, admin, "department", d.id, "create", d.name)
db.commit()
db.refresh(d)
return department_out(d)
@router.put("/departments/{dept_id}")
def update_department(dept_id: int, body: DepartmentIn, db: Session = Depends(get_db),
admin: User = Depends(require("admin"))):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
if body.name and body.name.strip() != d.name:
if db.scalar(select(Department).where(func.lower(Department.name) == body.name.strip().lower())):
raise HTTPException(400, "name_taken")
d.name = body.name.strip()
if body.active is not None:
d.active = body.active
record(db, admin, "department", d.id, "update", d.name)
db.commit()
return department_out(d)
class CopyCategoriesIn(BaseModel):
source: str | int
@router.post("/departments/{dept_id}/categories/copy")
def copy_categories(dept_id: int, body: CopyCategoriesIn, db: Session = Depends(get_db),
user: User = Depends(current_user)):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
_require_categories(user, d.id)
if body.source == "defaults":
existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == d.id))}
from ..seed import CATEGORIES
top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == d.id)) or 0
for i, (en, pt, section) in enumerate(CATEGORIES):
if en.lower() not in existing:
db.add(Category(department_id=d.id, name_en=en, name_pt=pt, section=section, sort_order=top + (i + 1) * 10))
else:
_copy_categories(db, d, body.source)
record(db, user, "department", d.id, "copy_categories", f"from {body.source}")
db.commit()
return {"ok": True}
class StepIn(BaseModel):
name: str = Field(max_length=120)
approver_mode: str = "role"
approver_ids: list[int] = []
class WorkflowIn(BaseModel):
steps: list[StepIn]
@router.put("/departments/{dept_id}/workflow")
def set_workflow(dept_id: int, body: WorkflowIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
if db.scalar(select(func.count(BudgetYear.id)).where(BudgetYear.department_id == d.id,
BudgetYear.status == "in_review")):
raise HTTPException(409, "workflow_in_use")
if len(body.steps) > 10:
raise HTTPException(400, "too_many_steps")
valid_users = set(db.scalars(select(User.id).where(User.role != "management"))) # Upper Management never approves
before = [s.name for s in d.steps]
d.steps.clear()
db.flush()
for i, step in enumerate(body.steps):
if not step.name.strip() or step.approver_mode not in ("role", "users"):
raise HTTPException(400, "invalid_step")
ids = [u for u in step.approver_ids if u in valid_users] if step.approver_mode == "users" else []
if step.approver_mode == "users" and not ids:
raise HTTPException(400, "step_needs_approvers")
d.steps.append(WorkflowStep(position=i, name=step.name.strip(), approver_mode=step.approver_mode,
approver_ids=ids))
record(db, admin, "department", d.id, "workflow", d.name, {"steps": [before, [s.name for s in body.steps]]})
db.commit()
db.refresh(d)
return department_out(d)
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@router.get("/departments/{dept_id}/template.xlsx")
def download_template(dept_id: int, lang: str | None = None, year: int | None = None, rate: float | None = None,
from_year_id: int | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)):
from datetime import date
from decimal import Decimal
d = db.get(Department, dept_id)
if not d:
raise HTTPException(404, "not_found")
lang = lang if lang in ("pt", "en") else user.language
lines = None
source = db.get(BudgetYear, from_year_id) if from_year_id else None
if source and source.department_id == d.id:
lines = [lv for cv in build_year_view(db, source).categories for lv in cv.lines]
year = year or (source.year + 1 if source else date.today().year + 1)
rate_value = Decimal(str(rate)) / 100 if rate is not None else (source.default_rate if source else Decimal(0))
data = build_template(db, d, lang, year, rate_value, lines)
safe = "".join(ch if ch.isalnum() else "_" for ch in d.name)
name = ("Modelo_Orcamento" if lang == "pt" else "Budget_Template") + f"_{safe}_{year}.xlsx"
record(db, user, "template", d.id, "download", name)
db.commit()
return Response(data, media_type=XLSX, headers={"Content-Disposition": f'attachment; filename="{name}"'})
+208
View File
@@ -0,0 +1,208 @@
from datetime import datetime, timezone
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import mfa
from ..audit import record
from ..config import settings
from ..db import get_db
from ..models import User
from ..security import COOKIE, current_user, hash_password, make_token, verify_password
from ..serializers import user_out
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginIn(BaseModel):
email: str
password: str
code: str | None = None # authenticator or recovery code, for users with MFA on
class ProfileIn(BaseModel):
name: str | None = None
language: str | None = None
theme: str | None = None
current_password: str | None = None
new_password: str | None = None
@router.post("/login")
def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
user = db.scalar(select(User).where(func.lower(User.email) == body.email.strip().lower()))
if not user or not user.active or not verify_password(body.password, user.password_hash):
record(db, user if user and user.active else None, "user", user.id if user else None, "login_failed",
body.email.strip()[:200])
db.commit()
raise HTTPException(401, "invalid_credentials")
if user.mfa_enabled:
if not (body.code or "").strip():
# password is right: the sign-in screen now asks for the code
return {"mfa_required": True}
if mfa.locked(user):
raise HTTPException(429, "mfa_locked")
kind = mfa.check(user, body.code)
if not kind:
record(db, user, "user", user.id, "mfa_failed", user.email)
db.commit()
raise HTTPException(429 if mfa.locked(user) else 401, "mfa_locked" if mfa.locked(user) else "invalid_mfa_code")
if kind == "recovery":
record(db, user, "user", user.id, "mfa_recovery_used", f"{len(user.mfa_recovery or [])} recovery codes left")
response.set_cookie(COOKIE, make_token(user), httponly=True, samesite="lax",
secure=settings.cookie_secure, max_age=settings.session_hours * 3600)
record(db, user, "user", user.id, "login")
db.commit()
return user_out(user)
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)):
try:
user = current_user(request, db)
record(db, user, "user", user.id, "logout")
db.commit()
except HTTPException:
pass
response.delete_cookie(COOKIE)
return {"ok": True}
@router.get("/me")
def me(user: User = Depends(current_user)):
return user_out(user)
@router.put("/me")
def update_me(body: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
if body.name:
user.name = body.name.strip()
if body.language in ("pt", "en"):
user.language = body.language
if body.theme in ("system", "light", "dark"):
user.theme = body.theme
if body.new_password:
if not body.current_password or not verify_password(body.current_password, user.password_hash):
raise HTTPException(400, "wrong_password")
if len(body.new_password) < 8:
raise HTTPException(400, "password_too_short")
user.password_hash = hash_password(body.new_password)
record(db, user, "user", user.id, "password_changed")
db.commit()
return user_out(user)
# --- MFA ----------------------------------------------------------------------------------------
class CodeIn(BaseModel):
code: str = ""
password: str = ""
@router.post("/mfa/setup")
def mfa_setup(user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Starts MFA set-up: a new secret to scan as a QR code. MFA is only on after a code is confirmed."""
if user.mfa_enabled:
raise HTTPException(409, "mfa_already_on")
secret = mfa.new_secret()
user.mfa_pending_secret = mfa.seal(secret)
db.commit()
uri = mfa.otpauth_uri(secret, user.email)
return {"secret": secret, "uri": uri, "qr_svg": mfa.qr_svg(uri)}
@router.post("/mfa/enable")
def mfa_enable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
secret = mfa.unseal(user.mfa_pending_secret)
if user.mfa_enabled or not secret:
raise HTTPException(409, "mfa_setup_first")
step = mfa.matching_step(secret, body.code)
if step is None:
raise HTTPException(400, "invalid_mfa_code")
codes, hashes = mfa.new_recovery_codes()
user.mfa_secret, user.mfa_pending_secret, user.mfa_enabled = user.mfa_pending_secret, None, True
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = datetime.now(timezone.utc), step, hashes
user.mfa_failed, user.mfa_locked_until = 0, None
record(db, user, "user", user.id, "mfa_enabled", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/recovery-codes")
def mfa_new_recovery_codes(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""New recovery codes (the old ones stop working). Needs a current code."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
codes, user.mfa_recovery = mfa.new_recovery_codes()
record(db, user, "user", user.id, "mfa_recovery_renewed", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/disable")
def mfa_disable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Turning MFA off needs the password and a current code (or a recovery code)."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not verify_password(body.password, user.password_hash):
raise HTTPException(400, "wrong_password")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
mfa.disable(user)
record(db, user, "user", user.id, "mfa_disabled", user.email)
db.commit()
return user_out(user)
# --- profile picture ----------------------------------------------------------------------------
AVATAR_MAX_BYTES = 5 * 1024 * 1024
AVATAR_SIZE = 256
def resize_avatar(data: bytes) -> tuple[bytes, str]:
"""Any common picture -> a square JPEG (or PNG when it has transparency) of at most 256 px."""
from PIL import Image, ImageOps, UnidentifiedImageError
try:
img = Image.open(BytesIO(data))
img.load()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
raise HTTPException(400, "not_an_image")
img = ImageOps.exif_transpose(img)
side = min(img.size)
left, top = (img.width - side) // 2, (img.height - side) // 2
img = img.crop((left, top, left + side, top + side))
img.thumbnail((AVATAR_SIZE, AVATAR_SIZE))
out = BytesIO()
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img.convert("RGBA").save(out, "PNG", optimize=True)
return out.getvalue(), "image/png"
img.convert("RGB").save(out, "JPEG", quality=88, optimize=True)
return out.getvalue(), "image/jpeg"
@router.put("/me/avatar")
async def upload_avatar(file: UploadFile = File(...), user: User = Depends(current_user), db: Session = Depends(get_db)):
data = await file.read(AVATAR_MAX_BYTES + 1)
if len(data) > AVATAR_MAX_BYTES:
raise HTTPException(413, "image_too_large")
user.avatar, user.avatar_type = resize_avatar(data)
user.avatar_updated_at = datetime.now(timezone.utc)
record(db, user, "user", user.id, "avatar_changed", user.email)
db.commit()
return user_out(user)
@router.delete("/me/avatar")
def delete_avatar(user: User = Depends(current_user), db: Session = Depends(get_db)):
user.avatar = user.avatar_type = user.avatar_updated_at = None
record(db, user, "user", user.id, "avatar_removed", user.email)
db.commit()
return user_out(user)
+246
View File
@@ -0,0 +1,246 @@
"""Mail account settings and the mail log (administrators), and sending reports by email (everyone)."""
import csv
import io
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.orm import Session
from .. import mailer
from ..audit import diff, record, snapshot
from ..db import get_db
from ..models import MailLog, MailSettings, User
from ..security import current_user, require
from .reports import FORMATS, KINDS, MIME, render_report, report_lang, workbook_file
from .years import get_year
router = APIRouter(prefix="/api", tags=["mail"])
PLAIN_FIELDS = ["enabled", "smtp_host", "smtp_port", "smtp_security", "smtp_username", "from_address", "from_name",
"reply_to", "signature", "save_to_sent", "imap_host", "imap_port", "imap_security",
"imap_same_login", "imap_username", "sent_folder"]
SECURITY = ("starttls", "ssl", "none")
MAX_ATTACHMENTS_BYTES = 20 * 1024 * 1024
def settings_out(cfg: MailSettings) -> dict:
out = {f: getattr(cfg, f) for f in PLAIN_FIELDS}
out.update(smtp_password_set=bool(cfg.smtp_password), imap_password_set=bool(cfg.imap_password),
ready=mailer.is_ready(cfg), updated_at=cfg.updated_at.isoformat() if cfg.updated_at else None,
updated_by=cfg.updated_by.name if cfg.updated_by else None)
return out
class MailSettingsIn(BaseModel):
enabled: bool | None = None
smtp_host: str | None = Field(default=None, max_length=255)
smtp_port: int | None = Field(default=None, ge=1, le=65535)
smtp_security: str | None = None
smtp_username: str | None = Field(default=None, max_length=255)
smtp_password: str | None = Field(default=None, max_length=500) # empty or missing: keep the saved one
from_address: str | None = Field(default=None, max_length=255)
from_name: str | None = Field(default=None, max_length=255)
reply_to: str | None = Field(default=None, max_length=255)
signature: str | None = Field(default=None, max_length=4000)
save_to_sent: bool | None = None
imap_host: str | None = Field(default=None, max_length=255)
imap_port: int | None = Field(default=None, ge=1, le=65535)
imap_security: str | None = None
imap_same_login: bool | None = None
imap_username: str | None = Field(default=None, max_length=255)
imap_password: str | None = Field(default=None, max_length=500)
sent_folder: str | None = Field(default=None, max_length=255)
@router.get("/mail/settings")
def read_settings(db: Session = Depends(get_db), _: User = Depends(require("admin"))):
cfg = mailer.get_settings(db)
db.commit()
return settings_out(cfg)
@router.put("/mail/settings")
def save_settings(body: MailSettingsIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
cfg = mailer.get_settings(db)
before = snapshot(cfg, PLAIN_FIELDS)
data = body.model_dump(exclude_unset=True)
for key in ("smtp_security", "imap_security"):
if key in data and data[key] not in SECURITY:
raise HTTPException(400, "invalid_security")
for key in ("from_address", "reply_to"):
if data.get(key):
try:
mailer.clean_addresses([data[key]])
except mailer.MailError:
raise HTTPException(400, "invalid_address")
for key in PLAIN_FIELDS:
if key in data and data[key] is not None:
setattr(cfg, key, data[key].strip() if isinstance(data[key], str) and key != "signature" else data[key])
changed_passwords = []
for key in ("smtp_password", "imap_password"):
if data.get(key):
setattr(cfg, key, mailer.encrypt(data[key]))
changed_passwords.append(key)
if cfg.enabled and not (cfg.smtp_host and cfg.from_address):
raise HTTPException(400, "mail_incomplete")
cfg.updated_at, cfg.updated_by_id = datetime.now(timezone.utc), admin.id
changes = diff(before, snapshot(cfg, PLAIN_FIELDS))
for key in changed_passwords:
changes[key] = ["•••", "••• (new)"]
record(db, admin, "mail_settings", 1, "update", ", ".join(changes) or "no changes", changes)
db.commit()
return settings_out(cfg)
class TestIn(BaseModel):
to: str | None = None
@router.post("/mail/test")
def test_mail(body: TestIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
"""Without an address: sign in only. With one: also send a short test message (logged)."""
cfg = mailer.get_settings(db)
if not (cfg.smtp_host and cfg.from_address):
raise HTTPException(400, "mail_incomplete")
if not body.to:
result = mailer.check(cfg)
record(db, admin, "mail_settings", 1, "check", f"smtp {result['smtp']}; imap {result['imap']}")
db.commit()
return result
try:
to = mailer.clean_addresses([body.to])
except mailer.MailError as exc:
raise HTTPException(400, f"invalid_address:{exc.detail}")
log = mailer.send(db, admin, cfg, to=to, cc=[], kind="test", context="test",
subject="Budget & Capex — test", attachments=[],
body="Mensagem de teste enviada pela aplicação Orçamento & Capex.\n"
"Test message sent by the Budget & Capex app.")
return log_out(log)
@router.get("/mail/status")
def mail_status(db: Session = Depends(get_db), _: User = Depends(current_user)):
cfg = db.get(MailSettings, 1)
return {"enabled": bool(cfg and mailer.is_ready(cfg)), "from": cfg.from_address if cfg else "",
"signature": cfg.signature if cfg else ""}
class ReportRef(BaseModel):
kind: str
fmt: str
class EmailIn(BaseModel):
to: list[str] = Field(default_factory=list)
cc: list[str] = Field(default_factory=list)
subject: str = Field(default="", max_length=300)
message: str = Field(default="", max_length=10000)
reports: list[ReportRef] = Field(default_factory=list)
lang: str | None = None
site_id: int | None = None
include_empty: bool = False
copy_me: bool = False
@router.post("/years/{year_id}/email")
def email_reports(year_id: int, body: EmailIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
cfg = db.get(MailSettings, 1)
if not cfg or not mailer.is_ready(cfg):
raise HTTPException(409, "mail_not_configured")
year = get_year(db, year_id)
try:
to = mailer.clean_addresses(body.to)
cc = mailer.clean_addresses(body.cc + ([user.email] if body.copy_me else []))
except mailer.MailError as exc:
raise HTTPException(400, f"invalid_address:{exc.detail}")
cc = [a for a in cc if a.lower() not in {t.lower() for t in to}]
if not to:
raise HTTPException(400, "no_recipients")
if len(to) + len(cc) > mailer.MAX_RECIPIENTS:
raise HTTPException(400, "too_many_recipients")
refs = {(r.kind, r.fmt) for r in body.reports}
if not refs or any(not ((k in KINDS and f in FORMATS and f != "html") or (k, f) == ("workbook", "xlsx"))
for k, f in refs):
raise HTTPException(400, "no_attachments")
lang = report_lang(body.lang, user)
attachments = []
for kind, fmt in sorted(refs):
if kind == "workbook": # the editable budget file
data, name = workbook_file(db, year, lang)
else:
data, name = render_report(db, year, kind, fmt, lang, body.site_id, body.include_empty)
attachments.append(mailer.Attachment(name, MIME[fmt], data))
if sum(len(a.data) for a in attachments) > MAX_ATTACHMENTS_BYTES:
raise HTTPException(400, "file_too_large")
subject = body.subject.strip() or f"{year.department.name} {year.year} — {year.title}"
log = mailer.send(db, user, cfg, to=to, cc=cc, subject=subject, body=body.message, attachments=attachments,
year_id=year.id, context=f"{year.department.name} {year.year}")
if log.status != "sent":
raise HTTPException(502, log.error.split(":")[0] or "smtp_error")
return log_out(log)
def log_out(m: MailLog) -> dict:
return {"id": m.id, "at": m.at.isoformat(), "user": m.user.name if m.user else None,
"user_email": m.user.email if m.user else None, "kind": m.kind, "year_id": m.year_id,
"context": m.context, "sender": m.sender, "to": (m.recipients or {}).get("to", []),
"cc": (m.recipients or {}).get("cc", []), "subject": m.subject, "body": m.body,
"attachments": m.attachments or [], "status": m.status, "error": m.error,
"sent_copy": m.sent_copy, "sent_copy_detail": m.sent_copy_detail, "message_id": m.message_id}
def _log_query(q: str | None, status: str | None, user_id: int | None, date_from: str | None, date_to: str | None):
query = select(MailLog)
if status:
query = query.where(MailLog.status == status)
if user_id:
query = query.where(MailLog.user_id == user_id)
if date_from:
query = query.where(MailLog.at >= _date(date_from))
if date_to:
query = query.where(MailLog.at < _date(date_to, end=True))
if q:
like = f"%{q.strip().lower()}%"
query = query.where(or_(func.lower(MailLog.subject).like(like), func.lower(MailLog.context).like(like),
func.lower(cast(MailLog.recipients, String)).like(like)))
return query
def _date(text: str, end: bool = False) -> datetime:
try:
d = datetime.fromisoformat(text[:10]).replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(400, "invalid_date")
return d + timedelta(days=1) if end else d
@router.get("/mail/log")
def mail_log(q: str | None = None, status: str | None = None, user_id: int | None = None,
date_from: str | None = None, date_to: str | None = None, limit: int = 100, offset: int = 0,
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
query = _log_query(q, status, user_id, date_from, date_to)
total = db.scalar(select(func.count()).select_from(query.subquery()))
rows = db.scalars(query.order_by(MailLog.at.desc(), MailLog.id.desc())
.limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all()
return {"total": total, "rows": [log_out(m) for m in rows]}
@router.get("/mail/log.csv")
def mail_log_csv(q: str | None = None, status: str | None = None, user_id: int | None = None,
date_from: str | None = None, date_to: str | None = None,
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
rows = db.scalars(_log_query(q, status, user_id, date_from, date_to).order_by(MailLog.at.desc())).all()
out = io.StringIO()
w = csv.writer(out, delimiter=";")
w.writerow(["date", "user", "kind", "budget", "from", "to", "cc", "subject", "attachments", "status", "error",
"sent_copy", "sent_copy_detail"])
for m in rows:
o = log_out(m)
w.writerow([o["at"], o["user"], o["kind"], o["context"], o["sender"], ", ".join(o["to"]), ", ".join(o["cc"]),
o["subject"], ", ".join(a["name"] for a in o["attachments"]), o["status"], o["error"],
o["sent_copy"], o["sent_copy_detail"]])
return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="mail_log.csv"'})
+91
View File
@@ -0,0 +1,91 @@
import re
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, Response
from sqlalchemy.orm import Session
from ..audit import record
from ..config import settings
from ..db import get_db
from ..models import BudgetYear, Site, User
from ..reports import excel, render
from ..security import current_user
from ..summary import build_year_view
from ..template import build_template
from .years import get_year
router = APIRouter(prefix="/api", tags=["reports"])
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
KINDS = ("cost-report", "budget")
FORMATS = ("html", "pdf", "xlsx")
MIME = {"html": "text/html; charset=utf-8", "pdf": "application/pdf", "xlsx": XLSX}
def _filename(kind: str, year: int, dept: str, site: str | None, ext: str) -> str:
stem = {"cost-report": "Capex_e_Custos", "budget": "Budget"}[kind]
clean = lambda text: re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_")
suffix = f"_{clean(site)}" if site else ""
return f"{stem}_{clean(dept)}_{year}{suffix}.{ext}"
def disposition(name: str) -> str:
"""Content-Disposition for any file name (accents included)."""
from urllib.parse import quote
ascii_name = name.encode("ascii", "replace").decode().replace("?", "_").replace('"', "")
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}"
def report_lang(lang: str | None, user: User) -> str:
return lang if lang in ("pt", "en") else (user.language or settings.default_language)
def render_report(db: Session, year: BudgetYear, kind: str, fmt: str, lang: str, site_id: int | None = None,
include_empty: bool = False, screen: bool = False) -> tuple[bytes, str]:
"""The report file and its download name. Shared by the download links and by email."""
site = db.get(Site, site_id).name if site_id and db.get(Site, site_id) else None
view = build_year_view(db, year, site_id if site else None)
name = _filename(kind, year.year, year.department.name, site, fmt)
if fmt == "xlsx":
data = (excel.cost_report_xlsx(view, lang, site) if kind == "cost-report"
else excel.budget_xlsx(view, lang, site, include_empty=True))
return data, name
html = (render.cost_report_html(view, lang, site, screen=screen) if kind == "cost-report"
else render.budget_html(view, lang, site, include_empty))
if fmt == "html":
return html.encode(), name
return render.to_pdf(html), name
@router.get("/years/{year_id}/reports/{kind}.{fmt}")
def report(year_id: int, kind: str, fmt: str, lang: str | None = None, site_id: int | None = None,
include_empty: bool = False, db: Session = Depends(get_db), user: User = Depends(current_user)):
if kind not in KINDS or fmt not in FORMATS:
raise HTTPException(404, "not_found")
year = get_year(db, year_id)
data, name = render_report(db, year, kind, fmt, report_lang(lang, user), site_id, include_empty,
screen=(fmt == "html"))
record(db, user, "report", year.id, "view" if fmt == "html" else "download",
f"{year.department.name} {year.year} · {kind}.{fmt}", year_id=year.id)
db.commit()
if fmt == "html":
return HTMLResponse(data.decode())
return Response(data, media_type=MIME[fmt], headers={"Content-Disposition": f'attachment; filename="{name}"'})
def workbook_file(db: Session, year: BudgetYear, lang: str) -> tuple[bytes, str]:
lines = [lv for cv in build_year_view(db, year).categories for lv in cv.lines]
data = build_template(db, year.department, lang, year.year, year.default_rate or 0, lines)
clean = re.sub(r"[^A-Za-z0-9]+", "_", year.department.name).strip("_")
return data, year.source_file or f"{'Orcamento' if lang == 'pt' else 'Budget'}_{clean}_{year.year}.xlsx"
@router.get("/years/{year_id}/workbook.xlsx")
def budget_workbook(year_id: int, lang: str | None = None, db: Session = Depends(get_db),
user: User = Depends(current_user)):
"""The budget as an editable Excel template, ready to be changed and uploaded again."""
year = get_year(db, year_id)
data, name = workbook_file(db, year, report_lang(lang, user))
record(db, user, "year", year.id, "download", f"{year.department.name} {year.year} · {name}", year_id=year.id)
db.commit()
return Response(data, media_type=XLSX, headers={"Content-Disposition": disposition(name)})
+460
View File
@@ -0,0 +1,460 @@
import os
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from decimal import Decimal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..audit import diff, record, snapshot
from ..calc import ExpressionError, clean_months, evaluate, line_figures
from ..db import get_db
from .. import notify
from ..importer import ImportErrorWithDetail, archived_name, current_year, import_workbook
from ..models import AuditEntry, BudgetYear, Category, CostLine, Department, ReopenRequest, Site, User
from ..permissions import (allowed_actions, is_management, can_reopen, current_step, pending_reopen, require_action,
require_department_editor, steps_of)
from ..security import current_user
from ..serializers import reopen_out, year_detail, year_out
from ..summary import build_year_view
router = APIRouter(prefix="/api", tags=["years"])
LINE_FIELDS = ["category_id", "site_id", "name", "description", "remarks", "prior_expr", "base_expr",
"adj_kind", "adj_value", "adj_reason", "monthly_kind", "monthly_values", "monthly_months", "sort_order"]
def get_year(db: Session, year_id: int) -> BudgetYear:
y = db.get(BudgetYear, year_id)
if not y:
raise HTTPException(404, "not_found")
return y
def get_department(db: Session, department_id: int | None) -> Department:
d = db.get(Department, department_id or 0)
if not d:
raise HTTPException(400, "invalid_department")
return d
@router.get("/years")
def list_years(department_id: int | None = None, archived: bool = False, db: Session = Depends(get_db),
user: User = Depends(current_user)):
"""Current budgets, or with archived=true the versions that were replaced by an upload."""
q = (select(BudgetYear).where(BudgetYear.archived.is_(archived))
.order_by(BudgetYear.year.desc(), BudgetYear.department_id, BudgetYear.archived_at.desc()))
if department_id:
q = q.where(BudgetYear.department_id == department_id)
return [year_out(y, build_year_view(db, y), user) for y in db.scalars(q)]
class YearIn(BaseModel):
department_id: int | None = None
year: int | None = Field(default=None, ge=2000, le=2100)
title: str | None = None
default_rate: float | None = Field(default=None, ge=-1, le=10)
notes: str | None = None
copy_from_id: int | None = None
@router.post("/years")
def create_year(body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
dept = get_department(db, body.department_id or user.department_id)
require_department_editor(user, dept.id)
if not body.year:
raise HTTPException(400, "missing_fields")
if current_year(db, dept.id, body.year):
raise HTTPException(400, "year_exists")
src = get_year(db, body.copy_from_id) if body.copy_from_id else None
if src and src.department_id != dept.id:
raise HTTPException(400, "invalid_department")
rate = Decimal(str(body.default_rate)) if body.default_rate is not None else (src.default_rate if src else Decimal(0))
y = BudgetYear(department_id=dept.id, year=body.year, title=body.title or f"Budget {body.year}",
default_rate=rate, notes=body.notes or "")
db.add(y)
db.flush()
if src:
# Last year's adjusted budget becomes this year's starting point for both actual and base;
# users then replace the actual with the real figure.
for line in src.lines:
f = line_figures(line, src.default_rate)
start = str(f.adjusted)
db.add(CostLine(
year_id=y.id, category_id=line.category_id, site_id=line.site_id, name=line.name,
description=line.description, remarks="", prior_expr=start, prior_actual=f.adjusted,
base_expr=start, base_amount=f.adjusted, adj_kind=line.adj_kind, adj_value=line.adj_value,
adj_reason=line.adj_reason, monthly_kind=line.monthly_kind, monthly_values=line.monthly_values,
monthly_months=line.monthly_months,
sort_order=line.sort_order))
record(db, user, "year", y.id, "create", f"{dept.name} {y.year}" + (f" from {src.year}" if src else ""),
year_id=y.id)
db.commit()
return year_out(y)
@router.post("/years/import")
async def import_year(file: UploadFile = File(...), year: int | None = Form(None), replace: bool = Form(False),
department_id: int | None = Form(None),
db: Session = Depends(get_db), user: User = Depends(current_user)):
dept = get_department(db, department_id or user.department_id)
require_department_editor(user, dept.id)
data = await file.read()
if len(data) > 20 * 1024 * 1024:
raise HTTPException(400, "file_too_large")
try:
result = import_workbook(db, data, user, dept.id, year=year, replace=replace,
filename=_clean_filename(file.filename))
except ImportErrorWithDetail as exc:
db.rollback()
raise HTTPException(409 if str(exc) in ("year_exists", "year_not_draft") else 400, str(exc))
y = current_year(db, dept.id, result.year)
archived = db.scalars(select(BudgetYear).where(BudgetYear.department_id == dept.id, BudgetYear.year == result.year,
BudgetYear.archived.is_(True))
.order_by(BudgetYear.archived_at.desc())).first() if replace else None
return {"archived": year_out(archived) if archived else None,"year": year_out(y), "lines": result.lines, "categories_created": result.categories_created,
"warnings": result.warnings}
def _clean_filename(name: str | None) -> str:
return (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()[:255]
@router.post("/years/import/check")
async def check_import(file: UploadFile = File(...), year: int | None = Form(None),
department_id: int | None = Form(None),
db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Which budget an upload would create, and whether it would replace (and archive) a current one."""
dept = get_department(db, department_id or user.department_id)
require_department_editor(user, dept.id)
data = await file.read()
if len(data) > 20 * 1024 * 1024:
raise HTTPException(400, "file_too_large")
try:
result = import_workbook(db, data, user, dept.id, year=year, check_only=True)
except ImportErrorWithDetail as exc:
raise HTTPException(400, str(exc))
existing = current_year(db, dept.id, result.year)
name = _clean_filename(file.filename)
out = {"year": result.year, "department": dept.name, "filename": name, "existing": None}
if existing:
now = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon"))
out["existing"] = {
**year_out(existing, build_year_view(db, existing)), "can_replace": existing.status == "draft",
"same_file": bool(existing.source_file) and existing.source_file.lower() == name.lower(),
"archived_title": archived_name(existing.title or f"Budget {existing.year}", now),
"archived_file": archived_name(existing.source_file, now) if existing.source_file else "",
}
return out
@router.get("/years/{year_id}")
def read_year(year_id: int, site_id: int | None = None, db: Session = Depends(get_db),
user: User = Depends(current_user)):
return year_detail(build_year_view(db, get_year(db, year_id), site_id), user)
@router.put("/years/{year_id}")
def update_year(year_id: int, body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "edit")
before = snapshot(y, ["title", "default_rate", "notes"])
if body.title is not None:
y.title = body.title
if body.default_rate is not None:
y.default_rate = Decimal(str(body.default_rate))
if body.notes is not None:
y.notes = body.notes
record(db, user, "year", y.id, "update", str(y.year), diff(before, snapshot(y, ["title", "default_rate", "notes"])),
year_id=y.id)
db.commit()
return year_out(y, user=user)
@router.delete("/years/{year_id}")
def delete_year(year_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "delete")
record(db, user, "year", y.id, "delete",
f"{y.department.name} {y.year} · {y.title} ({y.status}, {len(y.lines)} lines)", year_id=y.id)
db.delete(y)
db.commit()
return {"ok": True}
class StatusIn(BaseModel):
action: str # submit | approve | return | reopen
comment: str | None = Field(default=None, max_length=500)
@router.post("/years/{year_id}/status")
def change_status(year_id: int, body: StatusIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
if body.action not in ("submit", "approve", "return", "reopen"):
raise HTTPException(400, "invalid_action")
expected = {"submit": "draft", "approve": "in_review", "return": "in_review", "reopen": "approved"}[body.action]
if y.status != expected:
raise HTTPException(409, "invalid_transition")
if not allowed_actions(user, y)[body.action]:
raise HTTPException(403, "forbidden")
if body.action == "reopen" and not (body.comment or "").strip():
raise HTTPException(400, "reason_required")
now = datetime.now(timezone.utc)
before = y.status
steps = steps_of(y.department)
step = current_step(y)
summary = body.comment or ""
if body.action == "submit":
y.status, y.current_step = "in_review", 0
y.submitted_by_id, y.submitted_at = user.id, now
y.approved_by_id = y.approved_at = None
elif body.action == "approve":
index = y.current_step or 0
summary = (f"{step.name}: " if step else "") + summary
if index + 1 < len(steps):
y.current_step = index + 1
else:
y.status, y.current_step = "approved", None
y.approved_by_id, y.approved_at = user.id, now
else: # return / reopen
y.status, y.current_step = "draft", None
y.approved_by_id = y.approved_at = None
if body.action == "reopen":
_reopen(y, user, now)
record(db, user, "year", y.id, body.action, summary.strip(" :"),
{"status": [before, y.status], **({"step": step.name} if step else {})}, year_id=y.id)
db.commit()
return year_out(y, user=user)
def _reopen(y: BudgetYear, user: User, now: datetime) -> None:
y.status, y.current_step = "draft", None
y.approved_by_id = y.approved_at = None
pending = pending_reopen(y)
if pending: # reopening answers an open request
pending.status, pending.decided_by_id, pending.decided_at = "accepted", user.id, now
class ReopenIn(BaseModel):
reason: str = Field(default="", max_length=2000)
@router.post("/years/{year_id}/reopen-requests")
def request_reopen(year_id: int, body: ReopenIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Anyone may ask for an approved budget to be reopened, saying why. Approvers reopen it at once."""
y = get_year(db, year_id)
if is_management(user):
raise HTTPException(403, "forbidden")
reason = body.reason.strip()
if len(reason) < 5:
raise HTTPException(400, "reason_required")
if y.archived or y.status != "approved":
raise HTTPException(409, "invalid_transition")
if pending_reopen(y) and not can_reopen(user, y):
raise HTTPException(409, "reopen_already_requested")
now = datetime.now(timezone.utc)
req = ReopenRequest(year_id=y.id, requested_by_id=user.id, requested_at=now, reason=reason, status="pending",
decision_note="")
label = f"{y.department.name} {y.year}"
if can_reopen(user, y):
pending = pending_reopen(y)
if pending: # an approver reopening answers the open request
req = pending
req.decision_note = reason
else:
db.add(req)
db.flush()
db.refresh(y)
_reopen(y, user, now)
record(db, user, "year", y.id, "reopen", reason, {"status": ["approved", "draft"]}, year_id=y.id)
db.commit()
return {"reopened": True, "request": reopen_out(req), "year": year_out(y, user=user)}
db.add(req)
db.flush()
db.refresh(y)
record(db, user, "year", y.id, "reopen_requested", reason, year_id=y.id)
db.commit()
notify.send(db, user, y, notify.reopeners(db, y), f"Pedido de reabertura / Reopen request: {label}",
f"{user.name} pediu a reabertura do orçamento {label} ({y.title}).\n"
f"{user.name} asked for the {label} budget ({y.title}) to be reopened.\n\n"
f"Motivo / Reason:\n{reason}\n")
return {"reopened": False, "request": reopen_out(req), "year": year_out(y, user=user)}
class DecisionIn(BaseModel):
accept: bool
note: str = Field(default="", max_length=2000)
@router.post("/reopen-requests/{request_id}/decide")
def decide_reopen(request_id: int, body: DecisionIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
req = db.get(ReopenRequest, request_id)
if not req:
raise HTTPException(404, "not_found")
y = req.budget_year
if req.status != "pending" or y.status != "approved":
raise HTTPException(409, "invalid_transition")
if not can_reopen(user, y) or y.archived:
raise HTTPException(403, "forbidden")
if not body.accept and not body.note.strip():
raise HTTPException(400, "reason_required")
now = datetime.now(timezone.utc)
note = body.note.strip()
label = f"{y.department.name} {y.year}"
if body.accept:
_reopen(y, user, now)
req.decision_note = note
record(db, user, "year", y.id, "reopen", f"{req.reason}" + (f" — {note}" if note else ""),
{"status": ["approved", "draft"], "requested_by": req.requested_by.name if req.requested_by else None},
year_id=y.id)
else:
req.status, req.decided_by_id, req.decided_at, req.decision_note = "declined", user.id, now, note
record(db, user, "year", y.id, "reopen_declined", note, year_id=y.id)
db.commit()
if req.requested_by:
verdict = ("aceite / accepted: o orçamento voltou a rascunho / the budget is back in draft" if body.accept
else "recusado / declined")
notify.send(db, user, y, [req.requested_by], f"Pedido de reabertura {label}: {verdict.split(':')[0]}",
f"O seu pedido de reabertura do orçamento {label} foi {verdict}.\n"
f"Your request to reopen the {label} budget was {verdict}.\n\n"
f"{user.name}" + (f":\n{note}\n" if note else "\n"))
return {"request": reopen_out(req), "year": year_out(y, user=user)}
@router.get("/years/{year_id}/audit")
def year_audit(year_id: int, limit: int = 200, db: Session = Depends(get_db), _: User = Depends(current_user)):
get_year(db, year_id)
rows = db.scalars(select(AuditEntry).where(AuditEntry.year_id == year_id)
.order_by(AuditEntry.at.desc()).limit(min(limit, 1000))).all()
return [{"at": a.at.isoformat(), "user": a.user.name if a.user else None, "entity": a.entity,
"entity_id": a.entity_id, "action": a.action, "summary": a.summary, "changes": a.changes}
for a in rows]
class LineIn(BaseModel):
category_id: int | None = None
site_id: int | None = None
name: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=500)
remarks: str | None = None
prior_expr: str | None = Field(default=None, max_length=400)
base_expr: str | None = Field(default=None, max_length=400)
adj_kind: str | None = None
adj_value: float | None = None
adj_reason: str | None = Field(default=None, max_length=255)
monthly_kind: str | None = None
monthly_values: list[float] | None = None
monthly_months: list[int] | None = None
sort_order: int | None = None
def _apply_line(line: CostLine, body: LineIn, db: Session, department_id: int) -> None:
data = body.model_dump(exclude_unset=True)
if "category_id" in data:
cat = db.get(Category, data["category_id"] or 0)
if not cat or cat.department_id != department_id:
raise HTTPException(400, "invalid_category")
line.category_id = data["category_id"]
if "site_id" in data:
if data["site_id"] is not None and not db.get(Site, data["site_id"]):
raise HTTPException(400, "invalid_site")
line.site_id = data["site_id"]
for f in ("name", "description", "remarks", "adj_reason", "sort_order"):
if f in data and data[f] is not None:
setattr(line, f, data[f].strip() if isinstance(data[f], str) else data[f])
for f, target in (("prior_expr", "prior_actual"), ("base_expr", "base_amount")):
if f in data and data[f] is not None:
try:
value = evaluate(data[f])
except ExpressionError:
raise HTTPException(400, f"invalid_expression:{f}")
setattr(line, f, data[f].strip().lstrip("=") or "0")
setattr(line, target, value)
if "adj_kind" in data:
if data["adj_kind"] not in ("year_rate", "percent", "factor", "none"):
raise HTTPException(400, "invalid_adjustment")
line.adj_kind = data["adj_kind"]
if data.get("adj_value") is not None:
line.adj_value = Decimal(str(data["adj_value"]))
if "monthly_kind" in data:
if data["monthly_kind"] not in ("even", "selected", "manual"):
raise HTTPException(400, "invalid_monthly")
if data["monthly_kind"] == "manual" and line.monthly_kind != "manual" and not data.get("monthly_values"):
# Switching to typed months starts from the current even spread.
year = db.get(BudgetYear, line.year_id)
spread = line_figures(line, year.default_rate if year else Decimal(0)).months
line.monthly_values = [float(m.quantize(Decimal("0.01"))) for m in spread]
if line.monthly_kind == "manual" and data["monthly_kind"] != "manual" and data.get("base_expr") is None:
# Leaving typed months keeps the year total: it becomes the base, without a further adjustment.
total = sum((Decimal(str(v or 0)) for v in (line.monthly_values or [])), Decimal(0))
line.base_expr, line.base_amount = str(total), total
if data.get("adj_kind") is None:
line.adj_kind, line.adj_value = "none", Decimal(0)
line.monthly_kind = data["monthly_kind"]
if "monthly_values" in data:
values = data["monthly_values"]
if values is not None and len(values) != 12:
raise HTTPException(400, "invalid_monthly")
line.monthly_values = values
if line.monthly_kind == "manual" and not line.monthly_values:
line.monthly_values = [0.0] * 12
if "monthly_months" in data:
values = data["monthly_months"]
if values is not None and any(not 1 <= m <= 12 for m in values):
raise HTTPException(400, "invalid_monthly")
line.monthly_months = clean_months(values) or None
if line.monthly_kind == "selected" and not line.monthly_months:
raise HTTPException(400, "no_months_selected")
if not line.name:
raise HTTPException(400, "missing_fields")
@router.post("/years/{year_id}/lines")
def create_line(year_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
y = get_year(db, year_id)
require_action(user, y, "edit")
if not body.category_id or not body.name:
raise HTTPException(400, "missing_fields")
line = CostLine(year_id=y.id, name="", description="", remarks="", prior_expr="0", prior_actual=Decimal(0),
base_expr="0", base_amount=Decimal(0), adj_kind="year_rate", adj_value=Decimal(0),
adj_reason="", monthly_kind="even", sort_order=0)
_apply_line(line, body, db, y.department_id)
if not line.sort_order:
line.sort_order = max([l.sort_order for l in y.lines if l.category_id == line.category_id] or [0]) + 10
db.add(line)
db.flush()
record(db, user, "line", line.id, "create", line.name, snapshot(line, LINE_FIELDS), year_id=y.id)
db.commit()
return {"id": line.id}
def _line_and_year(db: Session, line_id: int) -> tuple[CostLine, BudgetYear]:
line = db.get(CostLine, line_id)
if not line:
raise HTTPException(404, "not_found")
return line, line.budget_year
@router.put("/lines/{line_id}")
def update_line(line_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
line, y = _line_and_year(db, line_id)
require_action(user, y, "edit")
before = snapshot(line, LINE_FIELDS)
_apply_line(line, body, db, y.department_id)
changes = diff(before, snapshot(line, LINE_FIELDS))
if changes:
record(db, user, "line", line.id, "update", line.name, changes, year_id=y.id)
db.commit()
return {"id": line.id}
@router.delete("/lines/{line_id}")
def delete_line(line_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)):
line, y = _line_and_year(db, line_id)
require_action(user, y, "edit")
record(db, user, "line", line.id, "delete", line.name, snapshot(line, LINE_FIELDS), year_id=y.id)
db.delete(line)
db.commit()
return {"ok": True}
+54
View File
@@ -0,0 +1,54 @@
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from .config import settings
from .db import get_db
from .models import User
COOKIE = "budget_session"
# Ordered by what each role may change. "management" (Upper Management) sits below editor: it only
# views, downloads and emails budgets and reports, in every department.
ROLES = ("viewer", "management", "editor", "approver", "admin")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except ValueError:
return False
def make_token(user: User) -> str:
exp = datetime.now(timezone.utc) + timedelta(hours=settings.session_hours)
return jwt.encode({"sub": str(user.id), "exp": exp}, settings.secret_key, algorithm="HS256")
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get(COOKIE)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
user = db.get(User, int(payload["sub"]))
if not user or not user.active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated")
return user
def require(min_role: str):
def dep(user: User = Depends(current_user)) -> User:
if ROLES.index(user.role) < ROLES.index(min_role):
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
return user
return dep
+56
View File
@@ -0,0 +1,56 @@
"""Default chart of accounts, taken from the 'Budget IT' sheet (same order)."""
CATEGORIES = [
("Revenue", "Receitas", "revenue"),
("Cost of Cell Phones", "Custos com telemóveis", "cost"),
("Cost of Internet Services", "Serviços de internet", "cost"),
("Cost of Local Calls", "Chamadas locais", "cost"),
("Cost of Long Distance Calls", "Chamadas internacionais", "cost"),
("Other Cost of Services", "Outros custos de serviços", "cost"),
("Administrative and General", "Administrativos e gerais", "cost"),
("Centralized Information System Charges", "Encargos de sistemas de informação centralizados", "cost"),
("Energy Management", "Gestão de energia", "cost"),
("Food and Beverage", "Comidas e bebidas", "cost"),
("Golf", "Golfe", "cost"),
("Hardware", "Hardware", "cost"),
("Health Club/Spa", "Health club/Spa", "cost"),
("Human Resources", "Recursos humanos", "cost"),
("Information Security", "Segurança da informação", "cost"),
("Information Systems", "Sistemas de informação", "cost"),
("Other", "Outros", "cost"),
("Parking", "Estacionamento", "cost"),
("Property Operation and Maintenance", "Operação e manutenção do edifício", "cost"),
("Rooms", "Quartos", "cost"),
("Sales and Marketing", "Vendas e marketing", "cost"),
("Telecommunications", "Telecomunicações", "cost"),
("Other IT Equipment", "Outro equipamento informático", "cost"),
("System Storage and Optimization", "Armazenamento e otimização de sistemas", "cost"),
("Complimentary Services and Gifts", "Serviços de cortesia e ofertas", "cost"),
("Contract Services", "Serviços contratados", "cost"),
("Corporate Office Reimbursables", "Reembolsos à sede", "cost"),
("Dues and Subscriptions", "Quotas e subscrições", "cost"),
("Entertainment-In-House", "Entretenimento interno", "cost"),
("Equipment Rental", "Aluguer de equipamento", "cost"),
("Miscellaneous", "Diversos", "cost"),
("Cleaning Supplies", "Material de limpeza", "cost"),
("Food Supplies", "Abastecimento alimentar", "cost"),
("Give Aways Supplies", "Brindes", "cost"),
("Guest Supplies", "Amenidades para hóspedes", "cost"),
("Laundry Supplies", "Material de lavandaria", "cost"),
("Office Supplies", "Material de escritório", "cost"),
("Operating Supplies", "Material operacional", "cost"),
("Other Beverage Supplies", "Outras bebidas", "cost"),
("Other Supplies", "Outros materiais", "cost"),
("Paper and Plastics Disposables", "Descartáveis de papel e plástico", "cost"),
("Spa Supplies", "Material de spa", "cost"),
("Tobacco Supplies", "Tabaco", "cost"),
("Wine Supplies", "Vinhos", "cost"),
("Training", "Formação", "cost"),
("Travel-Meals and Entertainment", "Viagens - refeições e entretenimento", "cost"),
("Travel-Other", "Viagens - outros", "cost"),
("Uniform Costs", "Fardamentos", "cost"),
("Uniform Laundry", "Lavandaria de fardamentos", "cost"),
("Internal Recharge", "Imputações internas", "cost"),
]
SITES = ["Sagres", "Quinta", "Chiado", "Oriente"]
+103
View File
@@ -0,0 +1,103 @@
from decimal import Decimal
from .calc import round2
from .models import BudgetYear, Category, Department, Site, User, WorkflowStep
from .permissions import allowed_actions, steps_of
from .summary import LineView, YearView
def money(value: Decimal | None) -> float | None:
return None if value is None else float(round2(value))
def pct(value: Decimal | None) -> float | None:
return None if value is None else float(Decimal(value).quantize(Decimal("0.01")))
def user_out(u: User) -> dict:
return {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "language": u.language,
"active": u.active, "department_id": u.department_id,
"department": u.department.name if u.department else None, "theme": u.theme or "system",
"mfa_enabled": bool(u.mfa_enabled), "mfa_enabled_at": u.mfa_enabled_at.isoformat() if u.mfa_enabled_at else None,
"mfa_recovery_left": len(u.mfa_recovery or []) if u.mfa_enabled else 0,
# the timestamp changes the picture's URL, so browsers fetch a new one after a change
"avatar_url": f"/api/users/{u.id}/avatar?v={int(u.avatar_updated_at.timestamp())}" if u.avatar_updated_at else None}
def step_out(s: WorkflowStep) -> dict:
return {"id": s.id, "position": s.position, "name": s.name, "approver_mode": s.approver_mode,
"approver_ids": s.approver_ids or []}
def department_out(d: Department) -> dict:
return {"id": d.id, "name": d.name, "active": d.active, "steps": [step_out(s) for s in steps_of(d)]}
def site_out(s: Site) -> dict:
return {"id": s.id, "name": s.name, "active": s.active}
def category_out(c: Category) -> dict:
return {"id": c.id, "department_id": c.department_id, "name_en": c.name_en, "name_pt": c.name_pt, "section": c.section,
"sort_order": c.sort_order, "show_when_empty": c.show_when_empty, "active": c.active}
def totals_out(t) -> dict:
return {"prior": money(t.prior), "base": money(t.base), "adjusted": money(t.adjusted),
"change": money(t.change), "change_pct": pct(t.change_pct), "months": [money(m) for m in t.months]}
def line_out(lv: LineView) -> dict:
line, f = lv.line, lv.figures
return {
"id": line.id, "category_id": line.category_id, "site_id": line.site_id, "name": line.name,
"description": line.description, "remarks": line.remarks,
"prior_expr": line.prior_expr, "base_expr": line.base_expr,
"adj_kind": line.adj_kind, "adj_value": float(line.adj_value or 0), "adj_reason": line.adj_reason,
"monthly_kind": line.monthly_kind, "monthly_values": line.monthly_values,
"monthly_months": line.monthly_months,
"sort_order": line.sort_order,
"prior": money(f.prior), "base": money(f.base), "factor": float(f.factor), "adjusted": money(f.adjusted),
"change": money(f.change), "change_pct": pct(f.change_pct), "months": [money(m) for m in f.months],
}
def year_out(y: BudgetYear, view: YearView | None = None, user: User | None = None) -> dict:
out = {
"id": y.id, "year": y.year, "title": y.title, "default_rate": float(y.default_rate or 0),
"department_id": y.department_id, "department": y.department.name,
"status": y.status, "notes": y.notes, "current_step": y.current_step,
"workflow": [step_out(s) for s in steps_of(y.department)],
"submitted_by": y.submitted_by.name if y.submitted_by else None,
"submitted_at": y.submitted_at.isoformat() if y.submitted_at else None,
"approved_by": y.approved_by.name if y.approved_by else None,
"approved_at": y.approved_at.isoformat() if y.approved_at else None,
"source_file": y.source_file or "", "archived": bool(y.archived),
"archived_at": y.archived_at.isoformat() if y.archived_at else None,
"archived_by": y.archived_by.name if y.archived_by else None,
"reopen_request": next((reopen_out(r) for r in y.reopen_requests if r.status == "pending"), None),
}
if user is not None:
out["actions"] = allowed_actions(user, y)
if view is not None:
out["costs"] = totals_out(view.costs)
out["revenue"] = totals_out(view.revenue)
return out
def reopen_out(r) -> dict:
return {"id": r.id, "status": r.status, "reason": r.reason, "requested_by": r.requested_by.name if r.requested_by else None,
"requested_at": r.requested_at.isoformat() if r.requested_at else None,
"decided_by": r.decided_by.name if r.decided_by else None,
"decided_at": r.decided_at.isoformat() if r.decided_at else None, "decision_note": r.decision_note}
def year_detail(view: YearView, user: User | None = None) -> dict:
out = year_out(view.year, view, user)
out["reopen_requests"] = [reopen_out(r) for r in reversed(view.year.reopen_requests)]
out["categories"] = [
{"category": category_out(cv.category), "totals": totals_out(cv.totals),
"lines": [line_out(lv) for lv in cv.lines]}
for cv in view.categories
]
return out
+66
View File
@@ -0,0 +1,66 @@
"""Groups a year's lines by category with computed figures; shared by the API and every report."""
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from .calc import LineFigures, Totals, line_figures
from .models import BudgetYear, Category, CostLine
@dataclass
class LineView:
line: CostLine
figures: LineFigures
@dataclass
class CategoryView:
category: Category
lines: list[LineView] = field(default_factory=list)
totals: Totals = field(default_factory=Totals)
def name(self, lang: str) -> str:
return (self.category.name_pt if lang == "pt" else "") or self.category.name_en
@dataclass
class YearView:
year: BudgetYear
categories: list[CategoryView]
revenue: Totals
costs: Totals
def with_lines(self, section: str | None = None) -> list[CategoryView]:
return [c for c in self.categories if c.lines and (section is None or c.category.section == section)]
def build_year_view(db: Session, year: BudgetYear, site_id: int | None = None) -> YearView:
categories = db.scalars(select(Category).where(Category.department_id == year.department_id)
.order_by(Category.sort_order, Category.id)).all()
lines = db.scalars(
select(CostLine)
.where(CostLine.year_id == year.id)
.options(selectinload(CostLine.site), selectinload(CostLine.category))
.order_by(CostLine.sort_order, CostLine.id)
).all()
views = {c.id: CategoryView(c) for c in categories}
for line in lines:
if site_id is not None and line.site_id != site_id:
continue
lv = LineView(line, line_figures(line, year.default_rate))
cv = views.get(line.category_id)
if cv is None: # a line whose category was moved to another department
cv = views[line.category_id] = CategoryView(line.category)
cv.lines.append(lv)
cv.totals.add(lv.figures)
revenue, costs = Totals(), Totals()
shown = []
for cv in views.values():
if not cv.lines and not (cv.category.active and cv.category.show_when_empty):
continue
shown.append(cv)
target = revenue if cv.category.section == "revenue" else costs
for lv in cv.lines:
target.add(lv.figures)
return YearView(year, shown, revenue, costs)
+316
View File
@@ -0,0 +1,316 @@
"""Blank (or pre-filled) Excel template for a department's budget, and the import of a filled one."""
from datetime import datetime
from decimal import Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .audit import record
from .calc import ExpressionError, evaluate
from .models import Category, Department, Site, User
TEMPLATE_SHEETS = {"Lines", "Linhas"}
HEADER_ROW = 8
TEXT = {
"en": {
"sheet": "Lines", "lists": "Lists", "help": "Instructions",
"title": "Budget template · {dept}",
"year": "Year", "rate": "Year rate (%)", "dept": "Department",
"cols": ["Category", "Line", "Description", "Site", "Prior-year actual", "Base (without adjustment)",
"Adjustment", "Adjustment value", "Reason for adjustment", "Remarks"],
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"split": "Split over months",
"adj": {"year_rate": "Year rate", "percent": "Percent", "factor": "Factor", "none": "None"},
"group": "Group",
"instructions": [
"How to fill in this template",
"1. On the 'Lines' sheet, set the year and the year rate (for example 5.5 for 5.5%).",
"2. Add one row per cost line, starting on row 9. Category and Line are required.",
"3. Category and Site have drop-down lists. Leave Site empty for costs shared by the group.",
"4. Prior-year actual and Base accept numbers or calculations such as =12*839.67.",
"5. Adjustment: 'Year rate' applies the year rate to the base; 'Percent' uses the Adjustment value as a %;",
" 'Factor' multiplies the base by the Adjustment value (e.g. 1.15); 'None' keeps the base.",
"6. Fill in the months (Jan to Dec) only for lines with uneven monthly amounts; the year total is then",
" the sum of the months and Base and Adjustment are ignored. Otherwise the year is split over 12.",
" To split the adjusted amount equally over some months only, list them in the last column",
" 'Split over months', for example 1-4 or Jan, Feb, Mar, Apr (leave Jan to Dec empty).",
"7. In the app, go to Budgets > Import Excel, choose the department and upload this file.",
],
},
"pt": {
"sheet": "Linhas", "lists": "Listas", "help": "Instruções",
"title": "Modelo de orçamento · {dept}",
"year": "Ano", "rate": "Taxa do ano (%)", "dept": "Departamento",
"cols": ["Categoria", "Linha", "Descrição", "Unidade", "Real do ano anterior", "Base (sem ajuste)",
"Ajuste", "Valor do ajuste", "Motivo do ajuste", "Observações"],
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"split": "Dividir pelos meses",
"adj": {"year_rate": "Taxa do ano", "percent": "Percentagem", "factor": "Fator", "none": "Sem ajuste"},
"group": "Grupo",
"instructions": [
"Como preencher este modelo",
"1. Na folha 'Linhas', indique o ano e a taxa do ano (por exemplo 5,5 para 5,5%).",
"2. Acrescente uma linha por custo, a partir da linha 9. Categoria e Linha são obrigatórias.",
"3. Categoria e Unidade têm listas. Deixe a Unidade vazia para custos do grupo.",
"4. Real do ano anterior e Base aceitam números ou cálculos como =12*839,67.",
"5. Ajuste: 'Taxa do ano' aplica a taxa do ano à base; 'Percentagem' usa o Valor do ajuste como %;",
" 'Fator' multiplica a base pelo Valor do ajuste (ex.: 1,15); 'Sem ajuste' mantém a base.",
"6. Preencha os meses (Jan a Dez) só para linhas com valores mensais diferentes; o total do ano passa",
" a ser a soma dos meses e a Base e o Ajuste são ignorados. Caso contrário o ano é dividido por 12.",
" Para dividir o valor com ajuste em partes iguais só por alguns meses, indique-os na última coluna",
" 'Dividir pelos meses', por exemplo 1-4 ou Jan, Fev, Mar, Abr (deixe Jan a Dez vazios).",
"7. Na aplicação, vá a Orçamentos > Importar Excel, escolha o departamento e carregue este ficheiro.",
],
},
}
MONTH_NAMES = {name.lower(): i for t in TEXT.values() for i, name in enumerate(t["months"], start=1)}
MONTH_NAMES.update({"feb": 2, "fev": 2, "apr": 4, "abr": 4, "may": 5, "mai": 5, "aug": 8, "ago": 8,
"sep": 9, "set": 9, "oct": 10, "out": 10, "dec": 12, "dez": 12})
def _month(token: str) -> int | None:
token = token.strip().lower().rstrip(".")
if token.isdigit():
return int(token) if 1 <= int(token) <= 12 else None
return MONTH_NAMES.get(token[:3])
def parse_months(value) -> list[int]:
"""'1-4', '1,2,3,4', 'Jan-Abr', 'jan; fev; mar' -> [1, 2, 3, 4]. Unreadable text gives []."""
if isinstance(value, (int, float)):
value = str(int(value))
out = set()
for part in str(value).replace(";", ",").replace("/", ",").replace(" e ", ",").replace(" and ", ",").split(","):
part = part.strip()
if not part:
continue
if "-" in part or "–" in part:
a, _, b = part.replace("–", "-").partition("-")
start, end = _month(a), _month(b)
if start is None or end is None or end < start:
return []
out.update(range(start, end + 1))
else:
m = _month(part)
if m is None:
return []
out.add(m)
return sorted(out)
def months_text(months: list[int]) -> str:
"""[1, 2, 3, 4, 7] -> '1-4, 7'."""
months, runs = sorted(months), []
for m in months:
if runs and m == runs[-1][1] + 1:
runs[-1][1] = m
else:
runs.append([m, m])
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in runs)
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
INPUT_FILL = PatternFill("solid", fgColor="FFF7E0")
def build_template(db: Session, department: Department, lang: str, year: int, rate: Decimal,
lines: list | None = None) -> bytes:
tx = TEXT.get(lang, TEXT["pt"])
cats = db.scalars(select(Category).where(Category.department_id == department.id, Category.active)
.order_by(Category.sort_order, Category.id)).all()
sites = db.scalars(select(Site).where(Site.active).order_by(Site.name)).all()
cat_name = (lambda c: c.name_pt or c.name_en) if lang == "pt" else (lambda c: c.name_en)
wb = Workbook()
ws = wb.active
ws.title = tx["sheet"]
ws["A1"] = tx["title"].format(dept=department.name)
ws["A1"].font = Font(bold=True, size=14)
for row, (label, value) in enumerate(((tx["dept"], department.name), (tx["year"], year),
(tx["rate"], float(rate * 100))), start=3):
ws.cell(row, 1, label).font = Font(bold=True)
c = ws.cell(row, 2, value)
if row > 3:
c.fill = INPUT_FILL
ws["B5"].number_format = "0.00"
headers = tx["cols"] + tx["months"] + [tx["split"]]
for col, label in enumerate(headers, start=1):
c = ws.cell(HEADER_ROW, col, label)
c.font, c.fill = Font(bold=True, color="FFFFFF"), HEAD_FILL
c.alignment = Alignment(wrap_text=True, vertical="center")
ws.row_dimensions[HEADER_ROW].height = 32
lists = wb.create_sheet(tx["lists"])
for i, c in enumerate(cats, start=1):
lists.cell(i, 1, cat_name(c))
for i, s in enumerate(sites, start=1):
lists.cell(i, 2, s.name)
for i, label in enumerate(tx["adj"].values(), start=1):
lists.cell(i, 3, label)
lists.sheet_state = "hidden"
last = HEADER_ROW + max(500, len(lines or []) + 200)
rng = lambda col: f"{get_column_letter(col)}{HEADER_ROW + 1}:{get_column_letter(col)}{last}"
for col, src, n in ((1, "A", len(cats)), (4, "B", len(sites)), (7, "C", 4)):
if n:
dv = DataValidation(type="list", formula1=f"'{tx['lists']}'!${src}$1:${src}${n}", allow_blank=True)
dv.error, dv.showErrorMessage = "", col != 1 # unknown categories are allowed and created on import
ws.add_data_validation(dv)
dv.add(rng(col))
row = HEADER_ROW + 1
for line in lines or []:
f = line.figures
ln = line.line
values = [cat_name(ln.category), ln.name, ln.description, ln.site.name if ln.site else "",
float(f.prior), float(f.base) if ln.monthly_kind != "manual" else None,
tx["adj"][ln.adj_kind] if ln.monthly_kind != "manual" else None,
float(ln.adj_value) if ln.adj_kind in ("percent", "factor") else None,
ln.adj_reason, ln.remarks]
if ln.monthly_kind == "manual":
values += [float(m) for m in f.months]
elif ln.monthly_kind == "selected" and ln.monthly_months:
values += [None] * 12 + [months_text(ln.monthly_months)]
for col, v in enumerate(values, start=1):
if v not in (None, ""):
ws.cell(row, col, v)
row += 1
for col in range(5, 7):
for r in range(HEADER_ROW + 1, last + 1):
ws.cell(r, col).number_format = "#,##0.00"
for col, width in zip(range(1, 11), (34, 40, 30, 14, 18, 20, 16, 14, 30, 36)):
ws.column_dimensions[get_column_letter(col)].width = width
for col in range(11, 23):
ws.column_dimensions[get_column_letter(col)].width = 11
ws.column_dimensions[get_column_letter(23)].width = 22
ws.freeze_panes = ws.cell(HEADER_ROW + 1, 3)
helpws = wb.create_sheet(tx["help"], 0)
for i, text in enumerate(tx["instructions"], start=1):
helpws.cell(i, 1, text).font = Font(bold=(i == 1), size=13 if i == 1 else 11)
helpws.cell(len(tx["instructions"]) + 2, 1, f"{tx['dept']}: {department.name} · {datetime.now():%Y-%m-%d}").font = Font(color="888888")
helpws.column_dimensions["A"].width = 110
wb.active = 1
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _norm(value) -> str:
return str(value or "").strip().lower()
def _amount(value, warnings, where) -> tuple[str, Decimal]:
if value is None or value == "":
return "0", Decimal(0)
if isinstance(value, (int, float)):
return str(value), Decimal(str(value))
try:
return str(value).strip().lstrip("="), evaluate(str(value))
except ExpressionError:
warnings.append(f"{where}: '{value}' is not a number, used 0")
return "0", Decimal(0)
def import_template(db: Session, wb, wb_formulas, user: User | None, department_id: int, year: int | None,
replace: bool, filename: str = "", check_only: bool = False):
from .importer import ImportErrorWithDetail, ImportResult, _category_index, prepare_year
from .models import CostLine
ws = next(w for w in wb.worksheets if w.title in TEMPLATE_SHEETS)
wsf = wb_formulas[ws.title]
def cell(r: int, c: int):
# Excel stores the computed value of =4*300; files written by other tools may only hold the formula.
v = ws.cell(r, c).value
return wsf.cell(r, c).value if v is None else v
labels = {**{_norm(t["year"]): "year" for t in TEXT.values()}, **{_norm(t["rate"]): "rate" for t in TEXT.values()}}
header_row, meta = None, {}
for r in range(1, 30):
a = _norm(ws.cell(r, 1).value)
if a in labels:
meta[labels[a]] = ws.cell(r, 2).value
if a in (_norm(t["cols"][0]) for t in TEXT.values()):
header_row = r
break
if header_row is None:
raise ImportErrorWithDetail("template_header_missing")
target_year = year or (int(meta["year"]) if isinstance(meta.get("year"), (int, float)) else None)
if not target_year:
raise ImportErrorWithDetail("year_not_found")
rate_value = meta.get("rate")
try:
rate = evaluate(str(rate_value)) / 100 if rate_value not in (None, "") else Decimal(0)
except ExpressionError:
rate = Decimal(0)
result = ImportResult(year=target_year)
if check_only:
return result
by = prepare_year(db, department_id, target_year, rate, replace, user, filename)
cats = _category_index(db, department_id)
sites = {_norm(s.name): s for s in db.scalars(select(Site))}
adj_kinds = {_norm(label): kind for t in TEXT.values() for kind, label in t["adj"].items()}
adj_kinds.update({"%": "percent", "factor ×": "factor", "fator ×": "factor"})
group_words = {_norm(t["group"]) for t in TEXT.values()}
next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10
order = 0
for r in range(header_row + 1, ws.max_row + 1):
row = [cell(r, c) for c in range(1, 24)]
cat_label, name = str(row[0] or "").strip(), str(row[1] or "").strip()
if not cat_label and not name:
continue
where = f"{ws.title} row {r}"
if not cat_label or not name:
result.warnings.append(f"{where}: category and line are required, row skipped")
continue
cat = cats.get(cat_label.lower())
if cat is None:
cat = Category(department_id=department_id, name_en=cat_label, name_pt=cat_label, section="cost",
sort_order=next_order)
next_order += 10
db.add(cat)
db.flush()
cats[cat_label.lower()] = cat
result.categories_created.append(cat_label)
site = None
if row[3] not in (None, "") and _norm(row[3]) not in group_words:
site = sites.get(_norm(row[3]))
if site is None:
result.warnings.append(f"{where}: unknown site '{row[3]}', saved as group")
prior_expr, prior = _amount(row[4], result.warnings, where)
base_expr, base = _amount(row[5], result.warnings, where)
kind = adj_kinds.get(_norm(row[6]), "year_rate") if row[6] not in (None, "") else "year_rate"
_, adj_value = _amount(row[7], result.warnings, where)
months = row[10:22]
manual = any(m not in (None, "") for m in months)
month_values = [float(_amount(m, result.warnings, where)[1]) for m in months] if manual else None
selected = None
if not manual and row[22] not in (None, ""):
selected = parse_months(row[22])
if not selected:
result.warnings.append(f"{where}: could not read the months '{row[22]}', split over 12")
order += 10
db.add(CostLine(
year_id=by.id, category_id=cat.id, site_id=site.id if site else None, name=name,
description=str(row[2] or ""), remarks=str(row[9] or ""), prior_expr=prior_expr, prior_actual=prior,
base_expr=base_expr, base_amount=base, adj_kind="none" if manual else kind, adj_value=adj_value,
adj_reason=str(row[8] or ""), monthly_values=month_values, monthly_months=selected,
monthly_kind="manual" if manual else "selected" if selected else "even",
sort_order=order))
result.lines += 1
record(db, user, "year", by.id, "import", f"Imported {result.lines} lines from template into {target_year}",
year_id=by.id)
db.commit()
return result
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
python -m app.preupgrade
alembic upgrade head
python -m app.bootstrap
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*' --workers "${API_WORKERS:-2}"
+13
View File
@@ -0,0 +1,13 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
SQLAlchemy==2.0.40
psycopg[binary]==3.2.6
alembic==1.15.2
openpyxl==3.1.5
weasyprint==65.1
Jinja2==3.1.6
PyJWT==2.10.1
bcrypt==4.3.0
python-multipart==0.0.20
cryptography==44.0.2
segno==1.6.1
+40
View File
@@ -0,0 +1,40 @@
import os
import sys
from pathlib import Path
import pytest
DB = Path(__file__).parent / "test.db"
os.environ["DATABASE_URL"] = f"sqlite:///{DB}"
os.environ["ADMIN_EMAIL"] = "admin@test.local"
os.environ["ADMIN_PASSWORD"] = "admin-pass-123"
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi.testclient import TestClient # noqa: E402
from app.bootstrap import bootstrap # noqa: E402
from app.db import Base, engine # noqa: E402
from app.main import app # noqa: E402
FIXTURE = Path(__file__).parent / "fixtures" / "budget_2027.xlsx"
H = {"X-Requested-With": "budget-app"}
@pytest.fixture()
def client():
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
bootstrap()
with TestClient(app) as c:
r = c.post("/api/auth/login", json={"email": "admin@test.local", "password": "admin-pass-123"}, headers=H)
assert r.status_code == 200
c.headers.update(H)
yield c
@pytest.fixture()
def imported(client):
with FIXTURE.open("rb") as f:
r = client.post("/api/years/import", files={"file": ("b.xlsx", f, "application/octet-stream")})
assert r.status_code == 200, r.text
return r.json()["year"]["id"]
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
from io import BytesIO
import openpyxl
import pytest
# Category totals from the "Capex e Custos 2027 v4" PDF. Lines are rounded to cents before
# summing, so a category can differ from the PDF by a cent or two.
PDF_TOTALS = {
"Contract Services": (600758.54, 615940.02),
"Cost of Cell Phones": (96532.00, 111011.80),
"Cost of Internet Services": (74160.00, 85284.00),
"Cost of Local Calls": (5575.00, 6269.13),
"Cost of Long Distance Calls": (11415.00, 12488.47),
"Other Cost of Services": (176847.00, 185226.36),
"Hardware": (37636.00, 83361.00),
"Property Operation and Maintenance": (31283.00, 55015.83),
"Other IT Equipment": (72566.00, 70747.24),
"Travel-Other": (14400.00, 16880.00),
}
def test_requires_login():
from fastapi.testclient import TestClient
from app.main import app
assert TestClient(app).get("/api/years").status_code == 401
def test_writes_need_header(client):
r = client.post("/api/years", json={"year": 2030}, headers={"X-Requested-With": ""})
assert r.status_code == 403
def test_import_matches_pdf(client, imported):
y = client.get(f"/api/years/{imported}").json()
assert y["year"] == 2027 and y["default_rate"] == pytest.approx(0.055)
got = {c["category"]["name_en"]: c["totals"] for c in y["categories"] if c["lines"]}
for name, (prior, adjusted) in PDF_TOTALS.items():
assert got[name]["prior"] == pytest.approx(prior, abs=0.005)
assert got[name]["adjusted"] == pytest.approx(adjusted, abs=0.02)
assert y["costs"]["prior"] == pytest.approx(1121172.54, abs=0.005)
assert y["costs"]["adjusted"] == pytest.approx(1242223.85, abs=0.05)
assert sum(len(c["lines"]) for c in y["categories"]) == 54
hw = next(c for c in y["categories"] if c["category"]["name_en"] == "Hardware")
oriente = next(l for l in hw["lines"] if "ORIENTE" in l["name"])
assert oriente["adj_kind"] == "factor" and oriente["adj_value"] == pytest.approx(3.055)
assert "Hardware price increase" in oriente["adj_reason"]
protel = next(l for c in y["categories"] for l in c["lines"] if l["name"] == "Protel")
assert protel["prior_expr"] == "5512*12"
def test_line_crud_and_month_spread(client, imported):
cats = client.get("/api/categories").json()
training = next(c for c in cats if c["name_en"] == "Training")
r = client.post(f"/api/years/{imported}/lines", json={
"category_id": training["id"], "name": "Microsoft certification", "prior_expr": "1000",
"base_expr": "=4*300", "adj_kind": "percent", "adj_value": 10})
assert r.status_code == 200, r.text
line_id = r.json()["id"]
y = client.get(f"/api/years/{imported}").json()
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
assert line["base"] == 1200 and line["adjusted"] == 1320 and sum(line["months"]) == pytest.approx(1320)
assert client.put(f"/api/lines/{line_id}", json={"base_expr": "abc"}).status_code == 400
r = client.put(f"/api/lines/{line_id}", json={"monthly_kind": "manual"})
assert r.status_code == 200
y = client.get(f"/api/years/{imported}").json()
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
assert line["monthly_values"][0] == 110 and line["adjusted"] == pytest.approx(1320)
assert client.delete(f"/api/lines/{line_id}").status_code == 200
audit = client.get(f"/api/years/{imported}/audit").json()
assert [a["action"] for a in audit[:3]] == ["delete", "update", "create"]
def test_status_workflow_and_roles(client, imported):
it = client.get("/api/departments").json()[0]["id"]
r = client.post("/api/users", json={"email": "ed@test.local", "name": "Ed", "role": "editor",
"password": "editor-pass-1", "department_id": it})
assert r.status_code == 200
from fastapi.testclient import TestClient
from app.main import app
ed = TestClient(app)
ed.headers.update({"X-Requested-With": "budget-app"})
assert ed.post("/api/auth/login", json={"email": "ed@test.local", "password": "editor-pass-1"}).status_code == 200
assert ed.get("/api/users").status_code == 403
assert ed.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
assert ed.post(f"/api/years/{imported}/status", json={"action": "approve"}).status_code == 403
line = client.get(f"/api/years/{imported}").json()["categories"][1]["lines"][0]
assert ed.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 409
assert client.post(f"/api/years/{imported}/status", json={"action": "approve"}).json()["status"] == "approved"
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen"}).status_code == 400 # reason needed
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen", "comment": "new contract"}).json()["status"] == "draft"
def test_rollover(client, imported):
r = client.post("/api/years", json={"year": 2028, "copy_from_id": imported})
assert r.status_code == 200
y = client.get(f"/api/years/{r.json()['id']}").json()
assert y["costs"]["prior"] == pytest.approx(1242223.85, abs=0.05)
@pytest.mark.parametrize("kind", ["cost-report", "budget"])
@pytest.mark.parametrize("lang", ["pt", "en"])
def test_reports(client, imported, kind, lang):
html = client.get(f"/api/years/{imported}/reports/{kind}.html?lang={lang}")
assert html.status_code == 200
assert ("<svg" if kind == "cost-report" else "<table") in html.text
xlsx = client.get(f"/api/years/{imported}/reports/{kind}.xlsx?lang={lang}")
assert xlsx.status_code == 200
wb = openpyxl.load_workbook(BytesIO(xlsx.content))
assert len(wb.sheetnames) >= 2
pdf = client.get(f"/api/years/{imported}/reports/{kind}.pdf?lang={lang}")
assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF"
+30
View File
@@ -0,0 +1,30 @@
from decimal import Decimal
import pytest
from app.calc import ExpressionError, evaluate, even_spread
@pytest.mark.parametrize("text,expected", [
("=5512*12", "66144"),
("3292.56+(12*839.67)+(12*148.19)", "15146.88"),
("4 839,65", "4839.65"),
("1.234,50", "1234.50"),
("1,234.50", "1234.50"),
("", "0"),
("-10/4", "-2.5"),
])
def test_evaluate(text, expected):
assert evaluate(text) == Decimal(expected)
@pytest.mark.parametrize("text", ["__import__('os')", "2**10", "1/0", "abc", "1;2"])
def test_evaluate_rejects(text):
with pytest.raises(ExpressionError):
evaluate(text)
def test_even_spread_adds_up():
months = even_spread(Decimal("100.00"))
assert sum(months) == Decimal("100.00")
assert months[0] == Decimal("8.33") and months[-1] == Decimal("8.37")
@@ -0,0 +1,127 @@
from io import BytesIO
import openpyxl
import pytest
from fastapi.testclient import TestClient
from app.main import app
H = {"X-Requested-With": "budget-app"}
def login(email, password):
c = TestClient(app)
c.headers.update(H)
assert c.post("/api/auth/login", json={"email": email, "password": password}).status_code == 200
return c
@pytest.fixture()
def org(client, imported):
"""IT (with the imported 2027 budget) and F&B, one editor and one approver in each."""
it = client.get("/api/departments").json()[0]
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
users = {}
for key, role, dept in (("it_ed", "editor", it), ("it_ap", "approver", it),
("fb_ed", "editor", fb), ("fb_ap", "approver", fb)):
r = client.post("/api/users", json={"email": f"{key}@t.local", "name": key, "role": role,
"password": "password-123", "department_id": dept["id"]})
assert r.status_code == 200, r.text
users[key] = login(f"{key}@t.local", "password-123")
return {"it": it, "fb": fb, "year": imported, **users}
def test_categories_are_per_department(client, org):
it_cats = client.get(f"/api/categories?department_id={org['it']['id']}").json()
fb_cats = client.get(f"/api/categories?department_id={org['fb']['id']}").json()
assert len(it_cats) == len(fb_cats) == 50
assert not {c["id"] for c in it_cats} & {c["id"] for c in fb_cats}
# a line in an IT budget cannot use an F&B category
r = client.post(f"/api/years/{org['year']}/lines", json={"category_id": fb_cats[0]["id"], "name": "x"})
assert r.status_code == 400 and r.json()["detail"] == "invalid_category"
# the same year can exist once per department
r = org["fb_ed"].post("/api/years", json={"year": 2027})
assert r.status_code == 200 and r.json()["department"] == "F&B"
def test_other_departments_can_only_view(org):
fb = org["fb_ed"]
y = fb.get(f"/api/years/{org['year']}").json()
assert y["department"] == "IT" and y["actions"]["edit"] is False
line = y["categories"][1]["lines"][0]
assert fb.put(f"/api/lines/{line['id']}", json={"name": "hack"}).status_code == 403
assert fb.post(f"/api/years/{org['year']}/status", json={"action": "submit"}).status_code == 403
assert fb.get(f"/api/years/{org['year']}/reports/cost-report.xlsx").status_code == 200
assert org["it_ed"].put(f"/api/lines/{line['id']}", json={"remarks": "ok"}).status_code == 200
def test_custom_workflow(client, org):
it = org["it"]["id"]
users = {u["email"]: u["id"] for u in client.get("/api/users").json()}
r = client.put(f"/api/departments/{it}/workflow", json={"steps": [
{"name": "Department head", "approver_mode": "role"},
{"name": "Finance", "approver_mode": "users", "approver_ids": [users["fb_ap@t.local"]]},
]})
assert r.status_code == 200 and [s["name"] for s in r.json()["steps"]] == ["Department head", "Finance"]
y = org["year"]
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}).json()["current_step"] == 0
# step 1: only IT approvers; the F&B approver is not on this step
assert org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
r = org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
assert r.json()["status"] == "in_review" and r.json()["current_step"] == 1
# step 2: only the named user
assert org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
r = org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
assert r.json()["status"] == "approved"
# the workflow cannot be changed while a budget is in review
org["it_ap"].post(f"/api/years/{y}/status", json={"action": "reopen", "comment": "fix a line"})
org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"})
assert client.put(f"/api/departments/{it}/workflow", json={"steps": []}).status_code == 409
def test_template_roundtrip(client, org):
fb = org["fb"]["id"]
r = org["fb_ed"].get(f"/api/departments/{fb}/template.xlsx?lang=pt&year=2028&rate=3")
assert r.status_code == 200
wb = openpyxl.load_workbook(BytesIO(r.content))
assert wb.sheetnames == ["Instruções", "Linhas", "Listas"]
ws = wb["Linhas"]
rows = [
["Formação", "Curso de barista", "", "Chiado", 1000, "=4*300", "Percentagem", 10, "", "nota"],
["Vinhos", "Carta de vinhos", "", "", 5000, 5000, "Fator", 1.2, "Preços", ""],
["Receitas", "Eventos", "", "Grupo", 0, None, None, None, "", ""] + [100] * 12,
["Nova categoria", "Algo novo", "", "Lua", 10, 10, "", None, "", ""],
]
for i, row in enumerate(rows):
for j, v in enumerate(row):
ws.cell(9 + i, j + 1, v)
buf = BytesIO()
wb.save(buf)
r = org["fb_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(fb)})
assert r.status_code == 200, r.text
res = r.json()
assert res["lines"] == 4 and res["categories_created"] == ["Nova categoria"]
assert any("Lua" in w for w in res["warnings"])
y = client.get(f"/api/years/{res['year']['id']}").json()
assert y["year"] == 2028 and y["default_rate"] == pytest.approx(0.03) and y["department"] == "F&B"
lines = {l["name"]: l for c in y["categories"] for l in c["lines"]}
assert lines["Curso de barista"]["adjusted"] == 1320
assert lines["Carta de vinhos"]["adjusted"] == 6000
assert lines["Eventos"]["adjusted"] == 1200 and lines["Eventos"]["monthly_kind"] == "manual"
assert lines["Algo novo"]["adjusted"] == pytest.approx(10.3)
# the IT editor cannot import into F&B
r = org["it_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(fb), "replace": "true"})
assert r.status_code == 403
def test_prefilled_template_reimports_same_totals(client, org):
it = org["it"]["id"]
r = client.get(f"/api/departments/{it}/template.xlsx?lang=en&from_year_id={org['year']}&year=2029&rate=5.5")
res = client.post("/api/years/import", files={"file": ("t.xlsx", r.content)}, data={"department_id": str(it)}).json()
assert res["lines"] == 54
a = client.get(f"/api/years/{org['year']}").json()["costs"]
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
assert a["adjusted"] == pytest.approx(b["adjusted"], abs=0.01) and a["prior"] == b["prior"]
+191
View File
@@ -0,0 +1,191 @@
import email
import pytest
from fastapi.testclient import TestClient
from app import mailer
from app.main import app
H = {"X-Requested-With": "budget-app"}
class FakeSMTP:
sent: list = []
fail_login = False
def __init__(self, host, port, timeout=None, context=None):
self.host, self.port = host, port
def ehlo(self):
pass
def starttls(self, context=None):
pass
def login(self, user, password):
if FakeSMTP.fail_login or password != "smtp-secret":
import smtplib
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
def send_message(self, msg, from_addr=None, to_addrs=None):
FakeSMTP.sent.append((from_addr, list(to_addrs), msg))
return {}
def quit(self):
pass
class FakeIMAP:
appended: list = []
folders = [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Itens Enviados"',
b'(\\HasNoChildren) "/" "Sent"']
def __init__(self, host, port, ssl_context=None, timeout=None):
pass
def login(self, user, password):
assert password == "smtp-secret"
def list(self):
return "OK", list(FakeIMAP.folders)
def append(self, folder, flags, date, data):
FakeIMAP.appended.append((folder, flags, data))
return "OK", [b"APPEND completed"]
def logout(self):
pass
@pytest.fixture()
def fake_mail(monkeypatch):
FakeSMTP.sent, FakeIMAP.appended, FakeSMTP.fail_login = [], [], False
monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP)
monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP)
monkeypatch.setattr(mailer.imaplib, "IMAP4_SSL", FakeIMAP)
monkeypatch.setattr(mailer.imaplib, "IMAP4", FakeIMAP)
def configure(client, **extra):
body = {"enabled": True, "smtp_host": "mail.local", "smtp_port": 587, "smtp_security": "starttls",
"smtp_username": "budget@local.test", "smtp_password": "smtp-secret",
"from_address": "budget@local.test", "from_name": "Budget", "save_to_sent": True,
"imap_host": "mail.local", "imap_port": 993, "imap_security": "ssl", **extra}
r = client.put("/api/mail/settings", json=body)
assert r.status_code == 200, r.text
return r.json()
def test_settings_hide_and_encrypt_passwords(client, fake_mail):
out = configure(client)
assert out["smtp_password_set"] and "smtp_password" not in out and out["ready"]
from app.db import SessionLocal
from app.models import MailSettings
with SessionLocal() as db:
stored = db.get(MailSettings, 1).smtp_password
assert stored and "smtp-secret" not in stored and mailer.decrypt(stored) == "smtp-secret"
# saving again without a password keeps it
configure(client, smtp_password="")
assert client.post("/api/mail/test", json={}).json() == {"smtp": "ok", "imap": "ok", "sent_folder": "Itens Enviados"}
def test_send_report_saves_sent_copy_and_logs(client, imported, fake_mail):
configure(client)
r = client.post(f"/api/years/{imported}/email", json={
"to": ["cfo@example.com; ops@example.com"], "cc": ["boss@example.com"], "subject": "Budget 2027",
"message": "Segue o orçamento.", "lang": "pt", "copy_me": True,
"reports": [{"kind": "cost-report", "fmt": "pdf"}, {"kind": "budget", "fmt": "xlsx"}]})
assert r.status_code == 200, r.text
log = r.json()
assert log["status"] == "sent" and log["sent_copy"] == "saved" and log["sent_copy_detail"] == "Itens Enviados"
assert log["to"] == ["cfo@example.com", "ops@example.com"]
assert log["cc"] == ["boss@example.com", "admin@test.local"]
sender, rcpts, msg = FakeSMTP.sent[0]
assert sender == "budget@local.test" and len(rcpts) == 4
names = [p.get_filename() for p in msg.iter_attachments()]
assert names == ["Budget_IT_2027.xlsx", "Capex_e_Custos_IT_2027.pdf"]
folder, flags, data = FakeIMAP.appended[0]
assert folder == '"Itens Enviados"' and flags == r"(\Seen)"
copy = email.message_from_bytes(data)
assert copy["Message-ID"] == msg["Message-ID"] and copy["Subject"] == "Budget 2027"
rows = client.get("/api/mail/log").json()
assert rows["total"] == 1 and rows["rows"][0]["user"] == "Administrator"
assert [a["name"] for a in rows["rows"][0]["attachments"]] == names
assert "cfo@example.com" in client.get("/api/mail/log.csv").text
acts = client.get("/api/activity?entity=mail").json()["rows"]
assert acts[0]["action"] == "send" and "cfo@example.com" in acts[0]["summary"]
def test_failed_send_is_logged(client, imported, fake_mail):
configure(client)
FakeSMTP.fail_login = True
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert r.status_code == 502 and r.json()["detail"] == "smtp_auth"
row = client.get("/api/mail/log?status=failed").json()["rows"][0]
assert row["error"].startswith("smtp_auth") and row["sent_copy"] == "off"
assert not FakeIMAP.appended
def test_sent_folder_found_by_name(fake_mail, monkeypatch):
monkeypatch.setattr(FakeIMAP, "folders", [b'(\\HasNoChildren) "." "INBOX"', b'(\\HasNoChildren) "." "INBOX.Sent"'])
assert mailer.find_sent_folder(FakeIMAP("h", 1), "") == "INBOX.Sent"
with pytest.raises(mailer.MailError) as exc:
mailer.find_sent_folder(FakeIMAP("h", 1), "Outbox")
assert exc.value.code == "sent_folder_not_found"
def test_mail_rules(client, imported, fake_mail):
# not configured yet
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert r.status_code == 409
configure(client)
bad = client.post(f"/api/years/{imported}/email", json={"to": ["not-an-address"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert bad.status_code == 400 and bad.json()["detail"].startswith("invalid_address")
assert client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], "reports": []}).status_code == 400
client.post("/api/users", json={"email": "v@t.local", "name": "Viewer", "role": "viewer",
"password": "password-123"})
viewer = TestClient(app)
viewer.headers.update(H)
viewer.post("/api/auth/login", json={"email": "v@t.local", "password": "password-123"})
# any signed-in user may email a report; only admins see the settings and the logs
assert viewer.get("/api/mail/status").json()["enabled"] is True
assert viewer.post(f"/api/years/{imported}/email", json={
"to": ["a@example.com"], "reports": [{"kind": "budget", "fmt": "xlsx"}]}).status_code == 200
for url in ("/api/mail/settings", "/api/mail/log", "/api/mail/log.csv", "/api/activity", "/api/activity.csv"):
assert viewer.get(url).status_code == 403, url
assert viewer.put("/api/mail/settings", json={"enabled": False}).status_code == 403
def test_only_admin_deletes_budgets(client, imported):
client.post("/api/users", json={"email": "ap@t.local", "name": "Approver", "role": "approver",
"password": "password-123", "department_id": 1})
ap = TestClient(app)
ap.headers.update(H)
ap.post("/api/auth/login", json={"email": "ap@t.local", "password": "password-123"})
assert ap.get(f"/api/years/{imported}").json()["actions"]["delete"] is False
assert ap.delete(f"/api/years/{imported}").status_code == 403
# an admin can delete it even after approval
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
assert client.get(f"/api/years/{imported}").json()["actions"]["delete"] is True
assert client.delete(f"/api/years/{imported}").status_code == 200
assert client.get(f"/api/years/{imported}").status_code == 404
row = client.get("/api/activity?action=delete&entity=year").json()["rows"][0]
assert row["user"] == "Administrator" and "IT 2027" in row["summary"] and row["budget_exists"] is False
def test_activity_log_records_who_does_what(client, imported):
client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"})
client.get(f"/api/years/{imported}/reports/budget.xlsx")
data = client.get("/api/activity").json()
actions = [(r["entity"], r["action"]) for r in data["rows"]]
assert ("report", "download") in actions and ("user", "login_failed") in actions and ("year", "import") in actions
assert data["rows"][0]["budget"] == "IT 2027" or data["rows"][0]["entity"] == "user"
only = client.get("/api/activity?entity=report").json()
assert only["total"] == 1 and only["rows"][0]["budget"] == "IT 2027"
assert "budget.xlsx" in client.get("/api/activity.csv?entity=report").text
+71
View File
@@ -0,0 +1,71 @@
from decimal import Decimal
from io import BytesIO
import openpyxl
from app.calc import selected_spread
def test_selected_spread_in_cents():
months = selected_spread(Decimal("1000.00"), [1, 2, 3])
assert months[:3] == [Decimal("333.33"), Decimal("333.33"), Decimal("333.34")]
assert sum(months) == Decimal("1000.00") and months[3:] == [0] * 9
assert selected_spread(Decimal("120"), [4, 1, 3, 2, 2]) == [30, 30, 30, 30] + [0] * 8
assert selected_spread(Decimal("12"), []) == [1] * 12
def _first_line(client, year_id):
return client.get(f"/api/years/{year_id}").json()["categories"][0]["lines"][0]
def test_line_split_over_chosen_months(client, imported):
line = _first_line(client, imported)
r = client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [4, 1, 2, 3, 3]})
assert r.status_code == 200, r.text
after = _first_line(client, imported)
assert after["monthly_kind"] == "selected" and after["monthly_months"] == [1, 2, 3, 4]
assert after["adjusted"] == line["adjusted"] # the year total does not change, only when it is spent
share = round(line["adjusted"] / 4, 2)
assert after["months"][0] == share and after["months"][4:] == [0] * 8
assert round(sum(after["months"]), 2) == line["adjusted"]
# the year's monthly totals follow; the monthly budget report shows the months
year = client.get(f"/api/years/{imported}").json()
assert year["costs"]["adjusted"] == client.get(f"/api/years/{imported}").json()["costs"]["adjusted"]
assert client.get(f"/api/years/{imported}/reports/budget.xlsx").status_code == 200
assert client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": []}).status_code == 400
assert client.put(f"/api/lines/{line['id']}", json={"monthly_months": [13]}).status_code == 400
# rolling over to next year keeps the chosen months
nxt = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}).json()
copied = _first_line(client, nxt["id"])
assert copied["monthly_kind"] == "selected" and copied["monthly_months"] == [1, 2, 3, 4]
# back to 12 months
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "even", "monthly_months": None})
assert _first_line(client, imported)["months"][11] != 0
def test_template_keeps_chosen_months(client, imported):
line = _first_line(client, imported)
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [1, 2, 3, 4, 7]})
dept = client.get("/api/departments").json()[0]["id"]
r = client.get(f"/api/departments/{dept}/template.xlsx?lang=pt&from_year_id={imported}&year=2030")
ws = openpyxl.load_workbook(BytesIO(r.content))["Linhas"]
assert ws.cell(8, 23).value == "Dividir pelos meses"
assert "1-4, 7" in [ws.cell(row, 23).value for row in range(9, 70)]
# a hand-written month list on a row without typed months
free = next(r for r in range(9, 70) if ws.cell(r, 2).value and ws.cell(r, 23).value is None
and all(ws.cell(r, c).value is None for c in range(11, 23)))
ws.cell(free, 23, "Set-Dez")
buf = BytesIO()
ws.parent.save(buf)
res = client.post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(dept)}).json()
lines = [ln for c in client.get(f"/api/years/{res['year']['id']}").json()["categories"] for ln in c["lines"]]
picked = [ln["monthly_months"] for ln in lines if ln["monthly_kind"] == "selected"]
assert [1, 2, 3, 4, 7] in picked and [9, 10, 11, 12] in picked
a = client.get(f"/api/years/{imported}").json()["costs"]
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
assert abs(a["adjusted"] - b["adjusted"]) < 0.01
+127
View File
@@ -0,0 +1,127 @@
from fastapi.testclient import TestClient
from app.main import app
from tests.test_mail import fake_mail # noqa: F401 (fixture)
H = {"X-Requested-With": "budget-app"}
def user(client, email, role, dept=1):
client.post("/api/users", json={"email": email, "name": email.split("@")[0], "role": role,
"password": "password-123", "department_id": dept})
c = TestClient(app)
c.headers.update(H)
assert c.post("/api/auth/login", json={"email": email, "password": "password-123"}).status_code == 200
return c
def approve(client, year_id):
assert client.post(f"/api/years/{year_id}/status", json={"action": "submit"}).status_code == 200
while client.get(f"/api/years/{year_id}").json()["status"] == "in_review":
assert client.post(f"/api/years/{year_id}/status", json={"action": "approve"}).status_code == 200
def test_reopen_request_needs_reason_and_approver(client, imported):
viewer = user(client, "view@t.local", "viewer", dept=None)
approver = user(client, "ap@t.local", "approver")
approve(client, imported)
y = viewer.get(f"/api/years/{imported}").json()
assert y["actions"]["request_reopen"] and not y["actions"]["reopen"]
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": ""}).status_code == 400
r = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Contrato novo de telecomunicações"})
assert r.status_code == 200 and r.json()["reopened"] is False
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "again please"}).status_code == 409
y = approver.get(f"/api/years/{imported}").json()
assert y["status"] == "approved" and y["reopen_request"]["requested_by"] == "view"
assert y["actions"]["decide_reopen"] and not viewer.get(f"/api/years/{imported}").json()["actions"]["decide_reopen"]
rid = y["reopen_request"]["id"]
assert viewer.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).status_code == 403
assert approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False}).status_code == 400 # say why
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Já aprovado pela direção"})
assert d.json()["request"]["status"] == "declined" and d.json()["year"]["status"] == "approved"
# a second request, accepted this time
rid = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Falta a linha de licenças"}).json()["request"]["id"]
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).json()
assert d["request"]["status"] == "accepted" and d["year"]["status"] == "draft"
hist = client.get(f"/api/years/{imported}").json()["reopen_requests"]
assert [h["status"] for h in hist] == ["accepted", "declined"]
acts = [r["action"] for r in client.get("/api/activity?entity=year").json()["rows"]]
assert {"reopen_requested", "reopen_declined", "reopen"} <= set(acts)
def test_approver_reopens_directly_with_reason(client, imported):
approver = user(client, "ap@t.local", "approver")
approve(client, imported)
r = approver.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Correção de valores"}).json()
assert r["reopened"] is True and r["year"]["status"] == "draft"
def test_download_and_reupload_archives_old_version(client, imported):
# downloads keep the name of the file the budget was loaded from
assert 'filename="b.xlsx"' in client.get(f"/api/years/{imported}/workbook.xlsx").headers["content-disposition"]
fresh = client.post("/api/years", json={"year": 2031}).json()["id"]
assert 'filename="Orcamento_IT_2031.xlsx"' in client.get(f"/api/years/{fresh}/workbook.xlsx?lang=pt").headers["content-disposition"]
client.delete(f"/api/years/{fresh}")
r = client.get(f"/api/years/{imported}/workbook.xlsx?lang=pt")
files = {"file": ("Orcamento_IT_2027.xlsx", r.content)}
check = client.post("/api/years/import/check", files=files, data={"department_id": "1"}).json()
assert check["year"] == 2027 and check["existing"]["id"] == imported and check["existing"]["can_replace"]
assert check["existing"]["archived_title"].startswith("Budget 2027 (")
# without confirming, nothing is replaced
assert client.post("/api/years/import", files=files, data={"department_id": "1"}).status_code == 409
res = client.post("/api/years/import", files=files, data={"department_id": "1", "replace": "true"}).json()
new_id = res["year"]["id"]
assert new_id != imported and res["year"]["title"] == "Budget 2027" and res["year"]["source_file"] == "Orcamento_IT_2027.xlsx"
old = client.get(f"/api/years/{imported}").json()
assert old["archived"] and old["title"].startswith("Budget 2027 (") and old["title"] != "Budget 2027"
assert not any(v for k, v in old["actions"].items() if k != "delete")
line = old["categories"][0]["lines"][0]
assert client.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code in (403, 409)
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code in (403, 409)
assert [y["id"] for y in client.get("/api/years").json()] == [new_id]
assert [y["id"] for y in client.get("/api/years?archived=true").json()] == [imported]
assert client.get(f"/api/years/{imported}/reports/budget.pdf").status_code == 200 # still viewable
assert abs(old["costs"]["adjusted"] - client.get(f"/api/years/{new_id}").json()["costs"]["adjusted"]) < 0.01
# the same file again: the downloaded name is kept, and the previous upload gets the date in its file name
again = client.get(f"/api/years/{new_id}/workbook.xlsx").content
check = client.post("/api/years/import/check", files={"file": ("Orcamento_IT_2027.xlsx", again)},
data={"department_id": "1"}).json()
assert check["existing"]["same_file"] and check["existing"]["archived_file"].startswith("Orcamento_IT_2027_")
client.post("/api/years/import", files={"file": ("Orcamento_IT_2027.xlsx", again)},
data={"department_id": "1", "replace": "true"})
names = [y["source_file"] for y in client.get("/api/years?archived=true").json()]
assert any(n.startswith("Orcamento_IT_2027_2") and n.endswith(".xlsx") for n in names)
def test_approved_budget_cannot_be_replaced(client, imported):
data = client.get(f"/api/years/{imported}/workbook.xlsx").content
approve(client, imported)
check = client.post("/api/years/import/check", files={"file": ("b.xlsx", data)}, data={"department_id": "1"}).json()
assert check["existing"]["can_replace"] is False
r = client.post("/api/years/import", files={"file": ("b.xlsx", data)}, data={"department_id": "1", "replace": "true"})
assert r.status_code == 409 and r.json()["detail"] == "year_not_draft"
def test_approver_reopening_answers_pending_request(client, imported):
viewer = user(client, "view@t.local", "viewer", dept=None)
approve(client, imported)
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Preciso de mudar a linha X"})
r = client.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Concordo, reabrir"}).json()
assert r["reopened"] and r["request"]["status"] == "accepted" and r["request"]["requested_by"] == "view"
assert len(client.get(f"/api/years/{imported}").json()["reopen_requests"]) == 1
def test_reopen_request_emails_the_approvers(client, imported, fake_mail):
from tests.test_mail import FakeSMTP, configure
configure(client)
user(client, "ap@t.local", "approver")
viewer = user(client, "view@t.local", "viewer", dept=None)
approve(client, imported)
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Licenças em falta"})
_, rcpts, msg = FakeSMTP.sent[-1]
assert set(rcpts) == {"admin@test.local", "ap@t.local"} and "Licenças em falta" in msg.get_content()
rid = client.get(f"/api/years/{imported}").json()["reopen_request"]["id"]
client.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Fica para o próximo ano"})
_, rcpts, msg = FakeSMTP.sent[-1]
assert rcpts == ["view@t.local"] and "Fica para o próximo ano" in msg.get_content()
+42
View File
@@ -0,0 +1,42 @@
from tests.test_mail import configure, fake_mail # noqa: F401 (fixture)
from tests.test_v5 import approve, user
def test_upper_management_views_downloads_and_emails_every_department(client, imported, fake_mail):
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
other = client.post("/api/years", json={"department_id": fb["id"], "year": 2027}).json()["id"]
boss = user(client, "boss@t.local", "management", dept=None)
assert boss.get("/api/auth/me").json()["role"] == "management"
years = boss.get("/api/years").json()
assert {y["department_id"] for y in years} == {1, fb["id"]}
assert [y["id"] for y in boss.get(f"/api/years?department_id={fb['id']}").json()] == [other]
for year_id in (imported, other):
y = boss.get(f"/api/years/{year_id}").json()
assert not any(y["actions"].values()), y["actions"]
assert boss.get(f"/api/years/{year_id}/workbook.xlsx").status_code == 200
assert boss.get(f"/api/years/{year_id}/reports/cost-report.pdf").status_code == 200
configure(client)
r = boss.post(f"/api/years/{other}/email", json={"to": ["cfo@example.com"], "reports": [{"kind": "cost-report", "fmt": "pdf"}]})
assert r.status_code == 200, r.text
def test_upper_management_changes_nothing(client, imported):
boss = user(client, "boss@t.local", "management", dept=1) # even with a department
line = client.get(f"/api/years/{imported}").json()["categories"][0]["lines"][0]
assert boss.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 403
assert boss.post("/api/years", json={"year": 2032}).status_code == 403
assert boss.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 403
assert boss.delete(f"/api/years/{imported}").status_code == 403
assert boss.post("/api/categories", json={"department_id": 1, "name_en": "X"}).status_code == 403
assert boss.get("/api/users").status_code == 403 and boss.get("/api/activity").status_code == 403
# never an approver, even when named in a workflow step
me = boss.get("/api/auth/me").json()["id"]
client.put("/api/departments/1/workflow", json={"steps": [{"name": "Board", "approver_mode": "users", "approver_ids": [me]}]})
y = boss.get(f"/api/years/{imported}").json()
assert not y["actions"]["approve"]
# and does not ask for reopening: that is for the people who work on budgets
client.put("/api/departments/1/workflow", json={"steps": []})
approve(client, imported)
y = boss.get(f"/api/years/{imported}").json()
assert y["status"] == "approved" and not y["actions"]["request_reopen"]
assert boss.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Quero rever"}).status_code == 403
+105
View File
@@ -0,0 +1,105 @@
import time
from io import BytesIO
from fastapi.testclient import TestClient
from PIL import Image
from app import mfa
from app.main import app
from tests.test_v5 import H, user
def login(email, password, code=None):
c = TestClient(app)
c.headers.update(H)
body = {"email": email, "password": password}
if code is not None:
body["code"] = code
return c, c.post("/api/auth/login", json=body)
def now_code(secret, offset=0):
return mfa.code_at(secret, int(time.time() // mfa.STEP) + offset)
def test_rfc6238_vectors():
import base64
s = base64.b32encode(b"12345678901234567890").decode().rstrip("=")
assert mfa.code_at(s, 59 // 30) == "287082" and mfa.code_at(s, 1111111109 // 30) == "081804"
def test_mfa_set_up_sign_in_recovery_and_reset(client):
ana = user(client, "ana@t.local", "editor")
setup = ana.post("/api/auth/mfa/setup").json()
assert setup["uri"].startswith("otpauth://totp/") and "<svg" in setup["qr_svg"]
secret = setup["secret"]
# not on until a code is confirmed
assert ana.get("/api/auth/me").json()["mfa_enabled"] is False
assert ana.post("/api/auth/mfa/enable", json={"code": "000000" if now_code(secret) != "000000" else "111111"}).status_code == 400
r = ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)}).json()
codes = r["recovery_codes"]
assert r["user"]["mfa_enabled"] and len(codes) == 8 and r["user"]["mfa_recovery_left"] == 8
listed = {u["email"]: u["mfa_enabled"] for u in client.get("/api/users").json()}
assert listed == {"admin@test.local": False, "ana@t.local": True}
# sign-in now asks for the code; no session without it
c, res = login("ana@t.local", "password-123")
assert res.status_code == 200 and res.json() == {"mfa_required": True}
assert c.get("/api/auth/me").status_code == 401
_, res = login("ana@t.local", "password-123", "123456" if now_code(secret, 1) != "123456" else "654321")
assert res.status_code == 401 and res.json()["detail"] == "invalid_mfa_code"
# the code from the enable step was used already; the next one works once
c, res = login("ana@t.local", "password-123", now_code(secret, 1))
assert res.status_code == 200 and res.json()["email"] == "ana@t.local" and c.get("/api/auth/me").status_code == 200
_, res = login("ana@t.local", "password-123", now_code(secret, 1))
assert res.status_code == 401 # replay refused
# a recovery code works once
c, res = login("ana@t.local", "password-123", codes[0].upper())
assert res.status_code == 200 and res.json()["mfa_recovery_left"] == 7
assert login("ana@t.local", "password-123", codes[0])[1].status_code == 401
# an administrator resets it (lost phone)
me = c.get("/api/auth/me").json()["id"]
assert ana.post(f"/api/users/{me}/mfa/reset").status_code == 403
assert client.post(f"/api/users/{me}/mfa/reset").json()["mfa_enabled"] is False
assert login("ana@t.local", "password-123")[1].json()["email"] == "ana@t.local"
acts = {r["action"] for r in client.get("/api/activity?entity=user").json()["rows"]}
assert {"mfa_enabled", "mfa_failed", "mfa_recovery_used", "mfa_reset"} <= acts
def test_mfa_locks_after_wrong_codes_and_turns_off_with_password_and_code(client):
ana = user(client, "ana@t.local", "viewer")
secret = ana.post("/api/auth/mfa/setup").json()["secret"]
ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)})
for _ in range(mfa.MAX_FAILED - 1):
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 401
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 429
assert login("ana@t.local", "password-123", now_code(secret, 1))[1].status_code == 429 # locked for a while
assert ana.post("/api/auth/mfa/disable", json={"password": "wrong", "code": ""}).status_code == 400
from app.db import SessionLocal
from app.models import User
with SessionLocal() as db:
u = db.query(User).filter_by(email="ana@t.local").one()
u.mfa_locked_until = None
db.commit()
assert ana.post("/api/auth/mfa/disable", json={"password": "password-123", "code": now_code(secret, 1)}).json()["mfa_enabled"] is False
def test_avatar_upload_resize_and_remove(client):
ana = user(client, "ana@t.local", "viewer")
buf = BytesIO()
Image.new("RGB", (900, 600), (200, 30, 30)).save(buf, "PNG")
assert ana.put("/api/auth/me/avatar", files={"file": ("x.txt", b"not a picture")}).status_code == 400
me = ana.put("/api/auth/me/avatar", files={"file": ("me.png", buf.getvalue(), "image/png")}).json()
assert me["avatar_url"].startswith(f"/api/users/{me['id']}/avatar?v=")
pic = client.get(me["avatar_url"]) # other users see it in lists
assert pic.status_code == 200 and pic.headers["content-type"] == "image/jpeg"
assert Image.open(BytesIO(pic.content)).size == (256, 256)
assert ana.delete("/api/auth/me/avatar").json()["avatar_url"] is None
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
def test_theme_is_saved(client):
assert client.get("/api/auth/me").json()["theme"] == "system"
assert client.put("/api/auth/me", json={"theme": "dark"}).json()["theme"] == "dark"
assert client.put("/api/auth/me", json={"theme": "pink"}).json()["theme"] == "dark"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
# Dumps the database once at start, every day at BACKUP_TIME (HH:MM, container time zone), and whenever
# the api asks for one through /requests (it does so before every database upgrade).
set -eu
: "${BACKUP_TIME:=02:30}"
: "${BACKUP_KEEP_DAYS:=30}"
mkdir -p /backups /requests
chmod 1777 /requests
rm -f /tmp/first-backup-done
dump() {
file="/backups/budget_$(date +%Y%m%d_%H%M%S)${1:-}.sql.gz"
if pg_dump --no-owner --clean --if-exists | gzip > "$file.tmp"; then
mv "$file.tmp" "$file"
echo "$(date -Iseconds) backup written: $file"
find /backups -name 'budget_*.sql.gz' -mtime +"$BACKUP_KEEP_DAYS" -delete
return 0
fi
rm -f "$file.tmp"
echo "$(date -Iseconds) backup FAILED" >&2
return 1
}
answer_requests() {
for request in /requests/*.request; do
[ -e "$request" ] || continue
id=$(basename "$request" .request)
echo "$(date -Iseconds) backup requested: $(cat "$request" 2>/dev/null)"
if dump _before_upgrade; then echo ok > "/requests/$id.done"; else echo failed > "/requests/$id.done"; fi
rm -f "$request"
done
# answers nobody picked up
find /requests -name '*.done' -mmin +60 -delete 2>/dev/null || true
}
until pg_isready -q; do sleep 2; done
dump || true
touch /tmp/first-backup-done
while true; do
now=$(date +%s)
next=$(date -d "$(date +%Y-%m-%d) $BACKUP_TIME" +%s 2>/dev/null || date -D '%Y-%m-%d %H:%M' -d "$(date +%Y-%m-%d) $BACKUP_TIME" +%s)
[ "$next" -le "$now" ] && next=$((next + 86400))
while [ "$(date +%s)" -lt "$next" ]; do
answer_requests
sleep 5
done
dump || true
done
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Usage (from the project folder): docker compose exec backup restore.sh /backups/budget_YYYYMMDD_HHMMSS.sql.gz
set -eu
file="${1:?give the backup file, e.g. /backups/budget_20270101_023000.sql.gz}"
echo "Restoring $file into database $PGDATABASE ..."
gunzip -c "$file" | psql -q -v ON_ERROR_STOP=1
echo "Done. Restart the api container: docker compose restart api"
+86
View File
@@ -0,0 +1,86 @@
name: budget-capex
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: budget
POSTGRES_USER: budget
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
TZ: ${TZ:-Europe/Lisbon}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U budget -d budget"]
interval: 10s
timeout: 5s
retries: 10
api:
build: ./backend
image: budget-capex-api:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
backup:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://budget:${POSTGRES_PASSWORD}@db:5432/budget
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADMIN_NAME: ${ADMIN_NAME:-Administrator}
DEFAULT_LANGUAGE: ${DEFAULT_LANGUAGE:-pt}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
SESSION_HOURS: ${SESSION_HOURS:-12}
TZ: ${TZ:-Europe/Lisbon}
volumes:
# the api asks the backup container for a fresh backup here before every database upgrade
- backup-requests:/requests
web:
build: ./frontend
image: budget-capex-web:latest
restart: unless-stopped
depends_on:
- api
environment:
SITE_ADDRESS: ${SITE_ADDRESS:-:80}
ports:
- "${HTTP_PORT:-8080}:80"
- "${HTTPS_PORT:-8443}:443"
volumes:
- caddy-data:/data
backup:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
entrypoint: ["/bin/sh", "/scripts/backup.sh"]
healthcheck:
test: ["CMD", "test", "-f", "/tmp/first-backup-done"]
interval: 5s
timeout: 3s
retries: 120
environment:
PGHOST: db
PGUSER: budget
PGDATABASE: budget
PGPASSWORD: ${POSTGRES_PASSWORD}
BACKUP_TIME: ${BACKUP_TIME:-02:30}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-30}
TZ: ${TZ:-Europe/Lisbon}
volumes:
- ./backup/backup.sh:/scripts/backup.sh:ro
- ./backup/restore.sh:/usr/local/bin/restore.sh:ro
- ${BACKUP_DIR:-./backups}:/backups
- backup-requests:/requests
volumes:
db-data:
caddy-data:
backup-requests:
+815
View File
@@ -0,0 +1,815 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Budget &amp; Capex app guide</title>
<style>
:root {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #eef1f5; --text: #1d1f23; --muted: #5f6570; --border: #d5dae1;
--primary: #1f3b63; --primary-soft: #e3ebf6; --accent: #e8632b; --accent-soft: #fdebe2; --green: #15803d;
--green-soft: #e1f3e7; --red: #b42318; --red-soft: #fbe4e2; --amber: #a15c07; --amber-soft: #fdf1dc; --line: #7a8494;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161a; --surface: #1d2026; --surface-2: #262a32; --text: #e8eaee; --muted: #a0a7b3; --border: #383d48;
--primary: #8db4ee; --primary-soft: #23324a; --accent: #f39a6b; --accent-soft: #3b2a22; --green: #5ad38a;
--green-soft: #1d3326; --red: #f48a80; --red-soft: #3b2222; --amber: #e8b45c; --amber-soft: #3a2f1c; --line: #8c95a3;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1040px; margin: 0 auto; padding: 32px 20px 64px; }
h1 { font-size: 30px; line-height: 1.2; margin: 0 0 6px; }
h2 { font-size: 22px; margin: 48px 0 10px; padding-top: 8px; border-top: 1px solid var(--border); }
h3 { font-size: 16px; margin: 24px 0 6px; }
p { margin: 8px 0; max-width: 78ch; }
.lead { color: var(--muted); font-size: 16px; }
.muted { color: var(--muted); }
code, .mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 13px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
.toc { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 4px 20px; padding: 14px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; margin: 20px 0; }
.toc a { color: var(--primary); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; margin: 14px 0; }
figure { margin: 16px 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
figure svg { width: 100%; height: auto; display: block; }
figcaption { color: var(--muted); font-size: 13px; margin-top: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: var(--surface-2); font-size: 12.5px; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
.table-wrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; margin: 14px 0; }
.table-wrap table th:first-child, .table-wrap table td:first-child { padding-left: 14px; }
.yes { color: var(--green); font-weight: 600; } .no { color: var(--muted); } .own { color: var(--amber); font-weight: 600; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.example { background: var(--primary-soft); border-radius: 10px; padding: 12px 16px; margin: 12px 0; }
.example .calc { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 13.5px; white-space: pre-wrap; }
.tag { display: inline-block; font-size: 12px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); }
ul { padding-left: 22px; } li { margin: 3px 0; }
/* diagram parts */
.bx { fill: var(--surface); stroke: var(--border); stroke-width: 1.5; }
.bx-p { fill: var(--primary-soft); stroke: var(--primary); stroke-width: 1.5; }
.bx-a { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.5; }
.bx-g { fill: var(--green-soft); stroke: var(--green); stroke-width: 1.5; }
.bx-r { fill: var(--red-soft); stroke: var(--red); stroke-width: 1.5; }
.bx-y { fill: var(--amber-soft); stroke: var(--amber); stroke-width: 1.5; }
.bx-n { fill: var(--surface-2); stroke: var(--border); stroke-width: 1.5; }
.zone { fill: none; stroke: var(--border); stroke-width: 1.5; stroke-dasharray: 6 5; }
.t { fill: var(--text); font: 600 14px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ts { fill: var(--muted); font: 12.5px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.tz { fill: var(--muted); font: 600 12px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: .05em; }
.ar { stroke: var(--line); stroke-width: 1.6; fill: none; }
.ar-d { stroke: var(--line); stroke-width: 1.4; fill: none; stroke-dasharray: 5 4; }
.ar-r { stroke: var(--red); stroke-width: 1.6; fill: none; }
.ar-g { stroke: var(--green); stroke-width: 1.8; fill: none; }
.lbl { fill: var(--muted); font: 12px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.head { fill: var(--line); } .head-r { fill: var(--red); } .head-g { fill: var(--green); }
@media (max-width: 720px) { .grid2 { grid-template-columns: 1fr; } h1 { font-size: 25px; } main { padding: 20px 16px 48px; } }
@media print {
body { background: #fff; font-size: 12.5px; }
main { max-width: none; padding: 0; }
h2 { break-before: page; border-top: 0; margin-top: 0; }
figure, .card, .example, .table-wrap, tr { break-inside: avoid; }
.toc { break-after: page; }
}
</style>
</head>
<body>
<main>
<h1>Budget &amp; Capex: how the app works</h1>
<p class="lead">Version 7.0 · a guide to what the app does, who can do what, how a budget moves from draft to approved, and how each field you change affects the numbers in the reports.</p>
<p class="muted">The screens are in Portuguese or English (PT/EN switch at the top). This guide uses the English names, with the Portuguese name in <em>italics</em> where it helps.</p>
<nav class="toc" aria-label="Contents">
<a href="#map">1. The app at a glance</a>
<a href="#arch">2. What runs on the server</a>
<a href="#data">3. What is stored</a>
<a href="#roles">4. Roles and permissions</a>
<a href="#life">5. A budget's life cycle</a>
<a href="#calc">6. How the numbers are calculated</a>
<a href="#fields">7. Every field and what it changes</a>
<a href="#months">8. Splitting a year into months</a>
<a href="#reports">9. Reports, downloads and email</a>
<a href="#excel">10. Excel: template, download, re-upload</a>
<a href="#reopen">11. Reopening an approved budget</a>
<a href="#admin">12. Admin: email, logs, backups, upgrades</a>
<a href="#signin">13. Signing in, MFA, appearance, picture</a>
</nav>
<!-- ============================================================ -->
<h2 id="map">1. The app at a glance</h2>
<p>Each department prepares one budget per year. A budget is a list of cost and revenue lines grouped by category. Each line has last year's actual amount, this year's base amount and an adjustment, and it is split over the months. From those lines the app produces two documents, on screen and in PDF and Excel: the <strong>Capex and Costs report</strong> (<em>Capex e Custos</em>) and the <strong>Monthly budget</strong> (<em>Orçamento mensal</em>).</p>
<figure>
<svg viewBox="0 0 1000 430" role="img" aria-labelledby="map-t">
<title id="map-t">Pages of the app and who sees them</title>
<defs>
<marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<text x="20" y="28" class="tz">EVERY USER</text>
<rect x="20" y="40" width="300" height="150" rx="12" class="bx-p"/>
<text x="40" y="70" class="t">Budgets (Orçamentos)</text>
<text x="40" y="94" class="ts">List per department and year</text>
<text x="40" y="114" class="ts">New year · Import Excel · Excel template</text>
<text x="40" y="134" class="ts">Archived versions</text>
<text x="40" y="160" class="ts">→ open a budget to edit, approve,</text>
<text x="40" y="178" class="ts"> download, email, request reopening</text>
<rect x="350" y="40" width="300" height="150" rx="12" class="bx-p"/>
<text x="370" y="70" class="t">Budget page</text>
<text x="370" y="94" class="ts">Lines by category, totals, year rate</text>
<text x="370" y="114" class="ts">Workflow steps and status</text>
<text x="370" y="134" class="ts">Download ▾ · Send by email · Upload Excel</text>
<text x="370" y="154" class="ts">History (changes and reopen requests)</text>
<text x="370" y="174" class="ts">Delete (administrators only)</text>
<rect x="680" y="40" width="300" height="150" rx="12" class="bx-p"/>
<text x="700" y="70" class="t">Reports (Relatórios)</text>
<text x="700" y="94" class="ts">Capex and Costs · Monthly budget</text>
<text x="700" y="114" class="ts">Choose language and site</text>
<text x="700" y="134" class="ts">View online · PDF · Excel</text>
<text x="700" y="154" class="ts">Send by email</text>
<text x="700" y="174" class="ts">My profile: picture, MFA, theme</text>
<path d="M320,115 H346" class="ar" marker-end="url(#h)"/>
<path d="M650,115 H676" class="ar" marker-end="url(#h)"/>
<text x="20" y="236" class="tz">APPROVERS</text>
<rect x="20" y="248" width="300" height="70" rx="12" class="bx-y"/>
<text x="40" y="276" class="t">Categories (Categorias)</text>
<text x="40" y="298" class="ts">Their own department's categories</text>
<rect x="20" y="334" width="300" height="70" rx="12" class="bx-g"/>
<text x="40" y="362" class="t">Upper Management: read-only</text>
<text x="40" y="384" class="ts">Budgets + Reports only, all departments</text>
<text x="350" y="236" class="tz">ADMINISTRATORS</text>
<rect x="350" y="248" width="200" height="70" rx="12" class="bx-a"/>
<text x="368" y="276" class="t">Departments</text>
<text x="368" y="298" class="ts">and their workflows</text>
<rect x="565" y="248" width="200" height="70" rx="12" class="bx-a"/>
<text x="583" y="276" class="t">Categories and sites</text>
<text x="583" y="298" class="ts">all departments</text>
<rect x="780" y="248" width="200" height="70" rx="12" class="bx-a"/>
<text x="798" y="276" class="t">Users</text>
<text x="798" y="298" class="ts">role, department, MFA on/off</text>
<rect x="350" y="334" width="305" height="70" rx="12" class="bx-a"/>
<text x="368" y="362" class="t">Activity (Atividade)</text>
<text x="368" y="384" class="ts">who did what, filters, CSV</text>
<rect x="675" y="334" width="305" height="70" rx="12" class="bx-a"/>
<text x="693" y="362" class="t">Email</text>
<text x="693" y="384" class="ts">sending account · mail log</text>
</svg>
<figcaption>Pages in the top menu. Upper Management sees only Budgets and Reports, for every department, and can view, download and email but not change anything. Approvers also see everything every user sees, and administrators see everything.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="arch">2. What runs on the server</h2>
<p>The app runs on one server on your network with Docker. People open it in a browser at <code>http://&lt;server&gt;:8080</code>. Nothing needs to be installed on the PCs.</p>
<figure>
<svg viewBox="0 0 1000 400" role="img" aria-labelledby="arch-t">
<title id="arch-t">Containers and how they connect</title>
<defs>
<marker id="h2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<rect x="20" y="150" width="170" height="90" rx="12" class="bx-n"/>
<text x="40" y="185" class="t">Browsers</text>
<text x="40" y="207" class="ts">PCs on the network</text>
<rect x="230" y="40" width="580" height="320" rx="14" class="zone"/>
<text x="250" y="66" class="tz">SERVER · DOCKER COMPOSE "budget-capex"</text>
<rect x="260" y="150" width="160" height="90" rx="12" class="bx-p"/>
<text x="278" y="183" class="t">web (Caddy)</text>
<text x="278" y="205" class="ts">screens, HTTPS,</text>
<text x="278" y="223" class="ts">passes /api on</text>
<rect x="460" y="150" width="160" height="90" rx="12" class="bx-p"/>
<text x="478" y="183" class="t">api (Python)</text>
<text x="478" y="205" class="ts">rules, calculations,</text>
<text x="478" y="223" class="ts">PDF and Excel</text>
<rect x="660" y="150" width="130" height="90" rx="12" class="bx-g"/>
<text x="678" y="183" class="t">db</text>
<text x="678" y="205" class="ts">PostgreSQL:</text>
<text x="678" y="223" class="ts">all the data</text>
<rect x="460" y="270" width="330" height="70" rx="12" class="bx-y"/>
<text x="478" y="298" class="t">backup</text>
<text x="478" y="320" class="ts">nightly dump + one before every upgrade</text>
<rect x="840" y="80" width="140" height="90" rx="12" class="bx-n"/>
<text x="858" y="113" class="t">Mail server</text>
<text x="858" y="135" class="ts">SMTP: send</text>
<text x="858" y="153" class="ts">IMAP: Sent copy</text>
<rect x="840" y="270" width="140" height="70" rx="12" class="bx-n"/>
<text x="858" y="298" class="t">backups/</text>
<text x="858" y="320" class="ts">folder on disk</text>
<path d="M190,195 H256" class="ar" marker-end="url(#h2)" marker-start="url(#h2)"/>
<text x="200" y="185" class="lbl">:8080</text>
<path d="M420,195 H456" class="ar" marker-end="url(#h2)"/>
<path d="M620,195 H656" class="ar" marker-end="url(#h2)" marker-start="url(#h2)"/>
<path d="M620,170 C700,120 760,125 836,125" class="ar" marker-end="url(#h2)"/>
<text x="690" y="112" class="lbl">emails</text>
<path d="M725,240 V266" class="ar" marker-end="url(#h2)" marker-start="url(#h2)"/>
<path d="M790,305 H836" class="ar" marker-end="url(#h2)"/>
<path d="M540,240 V266" class="ar-d" marker-end="url(#h2)"/>
<text x="548" y="258" class="lbl">"back up first"</text>
</svg>
<figcaption>Four containers. Your data lives in the db container's volume, not in the app folder, so replacing the folder with a new version keeps it. The dashed arrow is the request the api makes before it changes the database during an upgrade.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="data">3. What is stored</h2>
<figure>
<svg viewBox="0 0 1000 540" role="img" aria-labelledby="data-t">
<title id="data-t">Main records and how they relate</title>
<defs>
<marker id="h3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<rect x="380" y="20" width="240" height="78" rx="12" class="bx-a"/>
<text x="400" y="50" class="t">Department</text>
<text x="400" y="72" class="ts">e.g. IT, F&amp;B · name, active</text>
<rect x="20" y="150" width="220" height="92" rx="12" class="bx"/>
<text x="40" y="180" class="t">User</text>
<text x="40" y="202" class="ts">role, department, picture</text>
<text x="40" y="222" class="ts">MFA, theme, language</text>
<rect x="270" y="150" width="220" height="92" rx="12" class="bx"/>
<text x="290" y="180" class="t">Workflow step</text>
<text x="290" y="202" class="ts">ordered; approved by the</text>
<text x="290" y="222" class="ts">dept's approvers or named users</text>
<rect x="520" y="150" width="220" height="92" rx="12" class="bx"/>
<text x="540" y="180" class="t">Category</text>
<text x="540" y="202" class="ts">PT and EN name, cost/revenue,</text>
<text x="540" y="222" class="ts">order, show when empty</text>
<rect x="770" y="150" width="210" height="92" rx="12" class="bx-p"/>
<text x="790" y="180" class="t">Budget (year)</text>
<text x="790" y="202" class="ts">year, title, year rate, status,</text>
<text x="790" y="222" class="ts">current step, source file</text>
<rect x="770" y="300" width="210" height="110" rx="12" class="bx-p"/>
<text x="790" y="330" class="t">Line</text>
<text x="790" y="352" class="ts">category, site, prior actual,</text>
<text x="790" y="372" class="ts">base, adjustment, months,</text>
<text x="790" y="392" class="ts">remarks</text>
<rect x="520" y="300" width="220" height="70" rx="12" class="bx"/>
<text x="540" y="330" class="t">Site</text>
<text x="540" y="352" class="ts">Sagres, Quinta, Chiado, Oriente</text>
<rect x="770" y="450" width="210" height="70" rx="12" class="bx-y"/>
<text x="790" y="480" class="t">Reopen request</text>
<text x="790" y="502" class="ts">reason, status, decision</text>
<rect x="20" y="300" width="470" height="170" rx="12" class="bx-n"/>
<text x="40" y="330" class="t">Shared by the whole app</text>
<text x="40" y="356" class="ts">Activity log: every action by every user</text>
<text x="40" y="378" class="ts">Mail settings: the sending account (passwords encrypted)</text>
<text x="40" y="400" class="ts">Mail log: every email sent and whether it arrived</text>
<text x="40" y="422" class="ts">Archived versions: replaced budgets, read-only</text>
<text x="40" y="444" class="ts">Sites are shared by all departments</text>
<path d="M430,98 L140,146" class="ar" marker-end="url(#h3)"/>
<path d="M470,98 L390,146" class="ar" marker-end="url(#h3)"/>
<path d="M530,98 L620,146" class="ar" marker-end="url(#h3)"/>
<path d="M570,98 L860,146" class="ar" marker-end="url(#h3)"/>
<path d="M875,242 V296" class="ar" marker-end="url(#h3)"/>
<text x="883" y="275" class="lbl">many</text>
<path d="M770,335 H744" class="ar-d" marker-end="url(#h3)"/>
<path d="M740,225 L772,302" class="ar-d" marker-end="url(#h3)"/>
<path d="M980,196 H994 V485 H984" class="ar" marker-end="url(#h3)"/>
<text x="900" y="440" class="lbl">many</text>
</svg>
<figcaption>Solid arrows: "has many". Dashed: a line points to one category and optionally one site. A department's categories are its own, so an IT budget never shows F&amp;B lines.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="roles">4. Roles and permissions</h2>
<p>Every user has one role and belongs to one department. Everyone can <strong>see and export every budget</strong>, and switch between departments with the department list on the Budgets page. Only the budget's own department can change or approve it.</p>
<div class="card"><strong>Upper Management</strong> (<em>Gestão de Topo</em>, new in version 6) is a read-only role for directors. These users see every department and start on <em>All departments</em>. They can open any budget and its reports, download them (the budget file, PDF and Excel) and send them by email. They cannot create, import, edit, upload, submit, approve, reopen, request reopening or delete, they never count as approvers, and their menu shows only Budgets and Reports. A department is optional for them.</div>
<div class="table-wrap">
<table>
<thead><tr><th>What</th><th>Viewer</th><th>Upper Mgmt</th><th>Editor</th><th>Approver</th><th>Admin</th></tr></thead>
<tbody>
<tr><td>View budgets and reports, download PDF / Excel</td><td class="yes">all</td><td class="yes">all</td><td class="yes">all</td><td class="yes">all</td><td class="yes">all</td></tr>
<tr><td>Send reports by email (once email is set up)</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td></tr>
<tr><td>Request reopening of an approved budget (with reason)</td><td class="yes">yes</td><td class="no">no</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td></tr>
<tr><td>Create, import, edit a draft budget; upload a changed file</td><td class="no">no</td><td class="no">no</td><td class="own">own dept</td><td class="own">own dept</td><td class="yes">any</td></tr>
<tr><td>Submit a draft for review</td><td class="no">no</td><td class="no">no</td><td class="own">own dept</td><td class="own">own dept</td><td class="yes">any</td></tr>
<tr><td>Approve or return a workflow step</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="own">own dept*</td><td class="own">own dept*</td></tr>
<tr><td>Reopen directly, accept or decline reopen requests</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="own">own dept</td><td class="yes">any</td></tr>
<tr><td>Manage categories</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="own">own dept</td><td class="yes">any</td></tr>
<tr><td>Delete a budget (any status, typed confirmation)</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">yes</td></tr>
<tr><td>Own profile: picture, light/dark mode, language, password, turn own MFA on or off</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td><td class="yes">yes</td></tr>
<tr><td>See who has MFA on; reset another user's MFA</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">yes</td></tr>
<tr><td>Departments, workflows, users, sites, email settings, activity and mail logs</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">yes</td></tr>
</tbody>
</table>
</div>
<p class="muted">* A workflow step can instead name specific users who approve it; then only those users can approve that step, whatever their role (Upper Management users can never be named). An administrator can always return a budget in review to draft.</p>
<!-- ============================================================ -->
<h2 id="life">5. A budget's life cycle</h2>
<figure>
<svg viewBox="0 0 1000 480" role="img" aria-labelledby="life-t">
<title id="life-t">Budget statuses and the actions that move between them</title>
<defs>
<marker id="h4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
<marker id="h4r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head-r"/></marker>
<marker id="h4g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head-g"/></marker>
</defs>
<text x="20" y="40" class="ts">Create: New year (empty or copied from an earlier year) or Import Excel</text>
<path d="M90,50 V96" class="ar" marker-end="url(#h4)"/>
<rect x="20" y="100" width="170" height="90" rx="14" class="bx-n"/>
<text x="40" y="135" class="t">Draft</text>
<text x="40" y="157" class="ts">editable by the</text>
<text x="40" y="175" class="ts">department</text>
<rect x="290" y="80" width="400" height="130" rx="14" class="bx-y"/>
<text x="310" y="110" class="t">In review</text>
<rect x="310" y="125" width="110" height="60" rx="10" class="bx"/>
<text x="325" y="152" class="t">Step 1</text>
<text x="325" y="172" class="ts">e.g. IT director</text>
<rect x="455" y="125" width="110" height="60" rx="10" class="bx"/>
<text x="470" y="152" class="t">Step 2</text>
<text x="470" y="172" class="ts">e.g. Finance</text>
<text x="590" y="160" class="t">… n</text>
<path d="M420,155 H451" class="ar" marker-end="url(#h4)"/>
<path d="M565,155 H585" class="ar" marker-end="url(#h4)"/>
<rect x="790" y="100" width="190" height="90" rx="14" class="bx-g"/>
<text x="810" y="135" class="t">Approved</text>
<text x="810" y="157" class="ts">locked; view, export</text>
<text x="810" y="175" class="ts">and email only</text>
<path d="M190,135 H286" class="ar-g" marker-end="url(#h4g)"/>
<text x="200" y="125" class="lbl">Submit</text>
<path d="M690,145 H786" class="ar-g" marker-end="url(#h4g)"/>
<text x="702" y="135" class="lbl">Approve</text>
<text x="702" y="166" class="lbl">(last step)</text>
<path d="M330,210 C300,260 160,260 110,194" class="ar-r" marker-end="url(#h4r)"/>
<text x="200" y="276" class="lbl">Return to draft (approver of the step, or admin)</text>
<path d="M885,190 C885,380 300,390 96,194" class="ar-r" marker-end="url(#h4r)"/>
<text x="380" y="372" class="lbl">Reopen: approver/admin with a reason, or an accepted reopen request</text>
<rect x="20" y="396" width="300" height="66" rx="12" class="bx-n"/>
<text x="40" y="424" class="t">Archived 🔒</text>
<text x="40" y="446" class="ts">replaced by an upload; read-only</text>
<path d="M50,190 V392" class="ar-d" marker-end="url(#h4)"/>
<text x="58" y="330" class="lbl">Upload a file for</text>
<text x="58" y="346" class="lbl">the same year</text>
<rect x="660" y="406" width="320" height="56" rx="12" class="bx-r"/>
<text x="680" y="440" class="t">Delete: administrators only, any status</text>
</svg>
<figcaption>Only a draft can be changed. Each approval moves the budget to the next workflow step; the last approval makes it Approved. A department with no steps configured is approved in one step by its approvers.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="calc">6. How the numbers are calculated</h2>
<p>Every total in the app and in the reports is built from the lines, in this order. Change any input on the left, and everything to its right follows.</p>
<figure>
<svg viewBox="0 0 1000 470" role="img" aria-labelledby="calc-t">
<title id="calc-t">From a line's fields to the report totals</title>
<defs>
<marker id="h5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<text x="20" y="28" class="tz">YOU TYPE</text>
<text x="300" y="28" class="tz">THE APP WORKS OUT</text>
<text x="760" y="28" class="tz">YOU SEE</text>
<rect x="20" y="44" width="220" height="62" rx="10" class="bx-a"/>
<text x="36" y="70" class="t">Prior-year actual</text>
<text x="36" y="91" class="ts">number or 12*839,67</text>
<rect x="20" y="122" width="220" height="62" rx="10" class="bx-a"/>
<text x="36" y="148" class="t">Base (without adjustment)</text>
<text x="36" y="169" class="ts">number or calculation</text>
<rect x="20" y="200" width="220" height="62" rx="10" class="bx-a"/>
<text x="36" y="226" class="t">Adjustment</text>
<text x="36" y="247" class="ts">year rate · % · factor · none</text>
<rect x="20" y="278" width="220" height="62" rx="10" class="bx-a"/>
<text x="36" y="304" class="t">Year rate (budget)</text>
<text x="36" y="325" class="ts">Inflation + adjustments %</text>
<rect x="20" y="356" width="220" height="62" rx="10" class="bx-a"/>
<text x="36" y="382" class="t">Months</text>
<text x="36" y="403" class="ts">12 · chosen months · typed</text>
<rect x="300" y="200" width="160" height="62" rx="10" class="bx-p"/>
<text x="316" y="226" class="t">Factor</text>
<text x="316" y="247" class="ts">e.g. 1,055 or 1,15</text>
<rect x="510" y="122" width="190" height="62" rx="10" class="bx-p"/>
<text x="526" y="148" class="t">Adjusted amount</text>
<text x="526" y="169" class="ts">base × factor, to cents</text>
<rect x="510" y="44" width="190" height="62" rx="10" class="bx-p"/>
<text x="526" y="70" class="t">Change € and %</text>
<text x="526" y="91" class="ts">adjusted − prior</text>
<rect x="510" y="356" width="190" height="62" rx="10" class="bx-p"/>
<text x="526" y="382" class="t">12 monthly amounts</text>
<text x="526" y="403" class="ts">in cents, add up exactly</text>
<rect x="760" y="44" width="220" height="100" rx="10" class="bx-g"/>
<text x="776" y="72" class="t">Category totals</text>
<text x="776" y="94" class="ts">sum of its lines</text>
<text x="776" y="114" class="ts">(filtered by site if chosen)</text>
<rect x="760" y="160" width="220" height="100" rx="10" class="bx-g"/>
<text x="776" y="188" class="t">Budget totals</text>
<text x="776" y="210" class="ts">costs and revenue,</text>
<text x="776" y="230" class="ts">prior → adjusted, change</text>
<rect x="760" y="276" width="220" height="70" rx="10" class="bx-g"/>
<text x="776" y="304" class="t">Capex and Costs report</text>
<text x="776" y="326" class="ts">year totals and charts</text>
<rect x="760" y="362" width="220" height="70" rx="10" class="bx-g"/>
<text x="776" y="390" class="t">Monthly budget</text>
<text x="776" y="412" class="ts">Jan … Dec per line</text>
<path d="M240,231 H296" class="ar" marker-end="url(#h5)"/>
<path d="M240,309 C270,309 270,250 296,246" class="ar" marker-end="url(#h5)"/>
<text x="246" y="348" class="lbl">only for "year rate"</text>
<path d="M460,231 C490,231 485,160 506,158" class="ar" marker-end="url(#h5)"/>
<path d="M240,153 H506" class="ar" marker-end="url(#h5)"/>
<path d="M240,75 H506" class="ar" marker-end="url(#h5)"/>
<path d="M605,122 V110" class="ar" marker-end="url(#h5)"/>
<path d="M605,184 V352" class="ar" marker-end="url(#h5)"/>
<path d="M240,387 H506" class="ar" marker-end="url(#h5)"/>
<path d="M700,75 H756" class="ar" marker-end="url(#h5)"/>
<path d="M700,153 C730,153 730,100 756,100" class="ar" marker-end="url(#h5)"/>
<path d="M868,144 V156" class="ar" marker-end="url(#h5)"/>
<path d="M868,260 V272" class="ar" marker-end="url(#h5)"/>
<path d="M700,387 C730,387 730,397 756,397" class="ar" marker-end="url(#h5)"/>
</svg>
<figcaption>With <strong>Monthly amounts</strong> (typed months) the path is different: the year total is simply the sum of the twelve months, and Base and Adjustment are not used.</figcaption>
</figure>
<h3>The adjustment and the factor</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Adjustment (<em>Ajuste</em>)</th><th>Factor used</th><th>Example, base 96 532,00 €</th></tr></thead>
<tbody>
<tr><td>Year rate (<em>Taxa do ano</em>)</td><td>1 + the budget's year rate</td><td>rate 5,5 % → × 1,055 = <strong>101 841,26 €</strong></td></tr>
<tr><td>Percent (<em>%</em>)</td><td>1 + adjustment value ÷ 100</td><td>value 15 → × 1,15 = <strong>111 011,80 €</strong></td></tr>
<tr><td>Factor (<em>Fator ×</em>)</td><td>the adjustment value</td><td>value 2,055 (hardware price rise) → <strong>198 373,26 €</strong></td></tr>
<tr><td>None (<em>Sem ajuste</em>)</td><td>1</td><td><strong>96 532,00 €</strong></td></tr>
</tbody>
</table>
</div>
<div class="example">
<strong>Worked example (IT 2027, "Mobile phone subscriptions")</strong>
<div class="calc">Prior-year actual 96 532,00 €
Base 96 532,00 €
Adjustment Factor × 1,15
Adjusted 96 532,00 × 1,15 = 111 011,80 €
Change 111 011,80 − 96 532,00 = +14 479,80 € (+15,00 %)
Months chosen: Jan–Apr → 27 752,95 € each, May–Dec 0</div>
</div>
<h3>Rounding rules</h3>
<ul>
<li>Each line's adjusted amount is rounded to cents before it is added up, as the finance reports do. Totals are therefore exact sums of the lines you see.</li>
<li>Monthly amounts are in cents. The rounding difference goes to the last month (December, or the last chosen month), so the twelve months always add up to the adjusted amount.</li>
<li>Change % is blank when the prior-year actual is zero.</li>
</ul>
<h3>Typing amounts</h3>
<p>Amount fields work like Excel: <code>12*839,67+3292,56</code> or <code>=5512*12</code>. Both <code>1 234,56</code> and <code>1,234.56</code> are understood. The calculation is kept, so you can see later how a figure was built.</p>
<!-- ============================================================ -->
<h2 id="fields">7. Every field and what it changes</h2>
<h3>On a budget</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Field</th><th>What it does</th><th>What changes when you edit it</th></tr></thead>
<tbody>
<tr><td><strong>Year</strong></td><td>The budget year; one current budget per department and year.</td><td>Column headings ("2026 actual", "2027 adjusted"), report titles, file names.</td></tr>
<tr><td><strong>Title</strong></td><td>Name shown in lists and reports.</td><td>Budget list, report headers, email subject suggestion.</td></tr>
<tr><td><strong>Inflation + adjustments (%)</strong> <em>(Taxa do ano)</em></td><td>The year rate.</td><td>Every line whose adjustment is "Year rate": their factor, adjusted amount, months, and so all category and budget totals and both reports. Lines with %, factor or none are not affected.</td></tr>
<tr><td><strong>Copy from an earlier year</strong> (when creating)</td><td>Starts the new year from another year's lines.</td><td>Each copied line's prior actual and base both start at last year's adjusted amount; adjustment type, months and site are kept. Replace the actual with the real figure.</td></tr>
</tbody>
</table>
</div>
<h3>On a line</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Field</th><th>What it does</th><th>What changes when you edit it</th></tr></thead>
<tbody>
<tr><td><strong>Line</strong> (name) and <strong>Description</strong></td><td>Labels.</td><td>Text in the budget and the reports only.</td></tr>
<tr><td><strong>Category</strong></td><td>Which group the line belongs to (your department's categories).</td><td>Moves the amounts from one category total to another: category pages and charts in Capex and Costs, grouping in the Monthly budget. Budget totals stay the same, unless the category is a revenue category, in which case the amount counts as revenue instead of cost.</td></tr>
<tr><td><strong>Site</strong> <em>(Unidade)</em></td><td>Sagres, Quinta, Chiado, Oriente, or Group.</td><td>What appears when a page or report is filtered by site. "Group" lines appear only in the all-sites view.</td></tr>
<tr><td><strong>Prior-year actual</strong></td><td>What was spent last year.</td><td>The "prior" column, change € and change %, category and budget prior totals, and the comparison charts. Does <em>not</em> change the new year's amount.</td></tr>
<tr><td><strong>Base</strong> <em>(sem ajuste)</em></td><td>The new year's amount before the adjustment.</td><td>Adjusted amount → months → every total for the new year → both reports.</td></tr>
<tr><td><strong>Adjustment</strong> and its <strong>value</strong></td><td>How the base becomes the adjusted amount (see the table above).</td><td>Factor → adjusted amount → months → totals → reports.</td></tr>
<tr><td><strong>Reason for adjustment</strong></td><td>Why it went up or down.</td><td>Shown under the line and in the reports.</td></tr>
<tr><td><strong>Months</strong></td><td>How the year amount is spread: split over 12, split over chosen months, or typed monthly amounts.</td><td>Only the Monthly budget's month columns and the monthly totals. The year total does not change (except with typed amounts, where the year total <em>is</em> the sum of the months).</td></tr>
<tr><td><strong>Remarks</strong></td><td>Free notes.</td><td>Shown on the line; no effect on numbers.</td></tr>
</tbody>
</table>
</div>
<h3>On a category</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Field</th><th>What changes</th></tr></thead>
<tbody>
<tr><td><strong>Name (PT / EN)</strong></td><td>The heading in the language of the screen or the report.</td></tr>
<tr><td><strong>Section: cost or revenue</strong></td><td>Whether its lines count in the cost totals or the revenue totals.</td></tr>
<tr><td><strong>Order</strong></td><td>The order of categories on screen and in the reports.</td></tr>
<tr><td><strong>Show when empty</strong></td><td>Whether a category with no lines still appears (with zero) in the budget and reports.</td></tr>
<tr><td><strong>Active</strong></td><td>Inactive categories can't be chosen for new lines. Existing lines keep them. A category with lines can be deactivated, but not deleted.</td></tr>
</tbody>
</table>
</div>
<!-- ============================================================ -->
<h2 id="months">8. Splitting a year into months</h2>
<figure>
<svg viewBox="0 0 1000 300" role="img" aria-labelledby="mo-t">
<title id="mo-t">The three ways to split a line into months</title>
<text x="20" y="30" class="t">Split over 12 <tspan class="ts"> (Dividir por 12) · 12 000,00 €</tspan></text>
<g transform="translate(20,44)">
<rect x="0" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="80" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="160" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="240" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="320" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="400" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="480" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="560" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="640" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="720" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="800" y="0" width="74" height="44" rx="6" class="bx-p"/><rect x="880" y="0" width="74" height="44" rx="6" class="bx-p"/>
<g class="ts"><text x="8" y="18">Jan</text><text x="88" y="18">Feb</text><text x="168" y="18">Mar</text><text x="248" y="18">Apr</text><text x="328" y="18">May</text><text x="408" y="18">Jun</text><text x="488" y="18">Jul</text><text x="568" y="18">Aug</text><text x="648" y="18">Sep</text><text x="728" y="18">Oct</text><text x="808" y="18">Nov</text><text x="888" y="18">Dec</text></g>
<g class="t"><text x="8" y="37">1 000</text><text x="88" y="37">1 000</text><text x="168" y="37">1 000</text><text x="248" y="37">1 000</text><text x="328" y="37">1 000</text><text x="408" y="37">1 000</text><text x="488" y="37">1 000</text><text x="568" y="37">1 000</text><text x="648" y="37">1 000</text><text x="728" y="37">1 000</text><text x="808" y="37">1 000</text><text x="888" y="37">1 000</text></g>
</g>
<text x="20" y="128" class="t">Split over chosen months <tspan class="ts"> (Dividir pelos meses escolhidos) · Jan–Apr ticked</tspan></text>
<g transform="translate(20,142)">
<rect x="0" y="0" width="74" height="44" rx="6" class="bx-a"/><rect x="80" y="0" width="74" height="44" rx="6" class="bx-a"/><rect x="160" y="0" width="74" height="44" rx="6" class="bx-a"/><rect x="240" y="0" width="74" height="44" rx="6" class="bx-a"/><rect x="320" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="400" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="480" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="560" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="640" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="720" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="800" y="0" width="74" height="44" rx="6" class="bx-n"/><rect x="880" y="0" width="74" height="44" rx="6" class="bx-n"/>
<g class="ts"><text x="8" y="18">Jan</text><text x="88" y="18">Feb</text><text x="168" y="18">Mar</text><text x="248" y="18">Apr</text><text x="328" y="18">May</text><text x="408" y="18">Jun</text><text x="488" y="18">Jul</text><text x="568" y="18">Aug</text><text x="648" y="18">Sep</text><text x="728" y="18">Oct</text><text x="808" y="18">Nov</text><text x="888" y="18">Dec</text></g>
<g class="t"><text x="8" y="37">3 000</text><text x="88" y="37">3 000</text><text x="168" y="37">3 000</text><text x="248" y="37">3 000</text></g>
<g class="ts"><text x="328" y="37">0</text><text x="408" y="37">0</text><text x="488" y="37">0</text><text x="568" y="37">0</text><text x="648" y="37">0</text><text x="728" y="37">0</text><text x="808" y="37">0</text><text x="888" y="37">0</text></g>
</g>
<text x="20" y="226" class="t">Monthly amounts <tspan class="ts"> (Valores mensais) · each month typed; the year total is their sum</tspan></text>
<g transform="translate(20,240)">
<rect x="0" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="80" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="160" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="240" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="320" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="400" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="480" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="560" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="640" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="720" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="800" y="0" width="74" height="44" rx="6" class="bx-y"/><rect x="880" y="0" width="74" height="44" rx="6" class="bx-y"/>
<g class="ts"><text x="8" y="18">Jan</text><text x="88" y="18">Feb</text><text x="168" y="18">Mar</text><text x="248" y="18">Apr</text><text x="328" y="18">May</text><text x="408" y="18">Jun</text><text x="488" y="18">Jul</text><text x="568" y="18">Aug</text><text x="648" y="18">Sep</text><text x="728" y="18">Oct</text><text x="808" y="18">Nov</text><text x="888" y="18">Dec</text></g>
<g class="t"><text x="8" y="37">500</text><text x="88" y="37">500</text><text x="168" y="37">2 000</text><text x="248" y="37">800</text><text x="328" y="37">800</text><text x="408" y="37">800</text><text x="488" y="37">0</text><text x="568" y="37">0</text><text x="648" y="37">1 600</text><text x="728" y="37">800</text><text x="808" y="37">800</text><text x="888" y="37">3 400</text></g>
</g>
</svg>
<figcaption>The first two keep the year total (base × adjustment) and only decide <em>when</em> it is spent. Quick buttons on the line pick a quarter (Q1–Q4) or a half-year (H1, H2). Switching a line from typed months to a split keeps its year total as the base.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="reports">9. Reports, downloads and email</h2>
<div class="grid2">
<div class="card">
<h3 style="margin-top:0">Capex and Costs <span class="tag">Capex e Custos</span></h3>
<ul>
<li>Totals: prior-year actual vs adjusted, change € and %</li>
<li>Chart of each category, last year vs this year</li>
<li>Chart of the change by category</li>
<li>One page per category with its lines and reasons</li>
<li>Summary table</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0">Monthly budget <span class="tag">Orçamento mensal</span></h3>
<ul>
<li>Every line with its twelve months and year total</li>
<li>Category subtotals and monthly totals</li>
<li>Option: include empty categories</li>
<li>Excel version keeps live formulas</li>
</ul>
</div>
</div>
<p>On the <strong>Reports</strong> page you pick the budget, the <strong>language</strong> (Portuguese or English) and the <strong>site</strong>; the same choice applies to viewing online, PDF, Excel and email. On a <strong>budget page</strong>, <strong>Download ▾</strong> offers the editable budget file and both reports, and <strong>Send by email</strong> lets you attach any of them. That works in every status, including draft and approved.</p>
<p>Sending by email asks for recipients (To, Cc, "send me a copy"), a subject and a message, and which files to attach. The email goes out from the account the administrator set up, a copy is saved in that mailbox's Sent folder, and the mail log records it.</p>
<!-- ============================================================ -->
<h2 id="excel">10. Excel: template, download, re-upload</h2>
<figure>
<svg viewBox="0 0 1000 340" role="img" aria-labelledby="xl-t">
<title id="xl-t">Download, change and upload a budget file</title>
<defs>
<marker id="h6" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<rect x="20" y="40" width="200" height="90" rx="12" class="bx-p"/>
<text x="38" y="72" class="t">Draft budget</text>
<text x="38" y="94" class="ts">IT 2027 "Budget 2027"</text>
<text x="38" y="114" class="ts">in the app</text>
<rect x="270" y="40" width="200" height="90" rx="12" class="bx-n"/>
<text x="288" y="72" class="t">Download ▾</text>
<text x="288" y="94" class="ts">Budget file</text>
<text x="288" y="114" class="ts mono">Orcamento_IT_2027.xlsx</text>
<rect x="520" y="40" width="200" height="90" rx="12" class="bx-n"/>
<text x="538" y="72" class="t">Change it in Excel</text>
<text x="538" y="94" class="ts">lines, amounts, months</text>
<text x="538" y="114" class="ts">same file name</text>
<rect x="770" y="40" width="210" height="90" rx="12" class="bx-y"/>
<text x="788" y="72" class="t">Upload Excel</text>
<text x="788" y="94" class="ts">pop-up: "The file</text>
<text x="788" y="114" class="ts">already exists" · OK</text>
<path d="M220,85 H266" class="ar" marker-end="url(#h6)"/>
<path d="M470,85 H516" class="ar" marker-end="url(#h6)"/>
<path d="M720,85 H766" class="ar" marker-end="url(#h6)"/>
<rect x="520" y="200" width="460" height="100" rx="12" class="bx-g"/>
<text x="538" y="232" class="t">New current budget</text>
<text x="538" y="254" class="ts">the lines from your file; same title "Budget 2027"</text>
<text x="538" y="274" class="ts">"Loaded from Orcamento_IT_2027.xlsx" · editable draft</text>
<rect x="20" y="200" width="450" height="112" rx="12" class="bx-n"/>
<text x="38" y="232" class="t">Old version, archived 🔒</text>
<text x="38" y="254" class="ts">renamed "Budget 2027 (2026-10-28 23:00)"</text>
<text x="38" y="274" class="ts mono">Orcamento_IT_2027_2026-10-28_2300.xlsx</text>
<text x="38" y="296" class="ts">locked: view, export, email; admin may delete</text>
<path d="M875,130 V196" class="ar" marker-end="url(#h6)"/>
<path d="M800,130 C700,170 400,160 250,196" class="ar-d" marker-end="url(#h6)"/>
</svg>
<figcaption>Nothing is lost: the version you replace is kept, renamed with the date and time, and locked. Only a draft can be replaced. An approved or in-review budget must be reopened first. Archived versions are listed under Budgets → "Show archived versions".</figcaption>
</figure>
<p><strong>Excel template</strong> (Budgets → <em>Modelo Excel</em>) gives a workbook for a department and year, blank or pre-filled from an existing budget, in PT or EN. It has an Instructions sheet and a Lines sheet with drop-down lists for category, site and adjustment, twelve month columns, and a last column <em>Split over months</em> where you can write <code>1-4</code> or <code>Jan, Fev, Mar, Abr</code>. Unknown categories are created on import; anything skipped is listed. The original "Budget para 2027" workbook layout can also be imported.</p>
<!-- ============================================================ -->
<h2 id="reopen">11. Reopening an approved budget</h2>
<figure>
<svg viewBox="0 0 1000 250" role="img" aria-labelledby="ro-t">
<title id="ro-t">Reopen request flow</title>
<defs>
<marker id="h7" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
</defs>
<rect x="20" y="70" width="190" height="90" rx="12" class="bx-g"/>
<text x="38" y="102" class="t">Approved budget</text>
<text x="38" y="124" class="ts">any user but Upper Mgmt</text>
<text x="38" y="142" class="ts">clicks "Request reopening"</text>
<rect x="260" y="70" width="210" height="90" rx="12" class="bx-y"/>
<text x="278" y="102" class="t">Reason (mandatory)</text>
<text x="278" y="124" class="ts">at least 5 characters;</text>
<text x="278" y="142" class="ts">request is pending</text>
<rect x="520" y="70" width="210" height="90" rx="12" class="bx"/>
<text x="538" y="102" class="t">Approvers + admins</text>
<text x="538" y="124" class="ts">see it on the budget,</text>
<text x="538" y="142" class="ts">get an email</text>
<rect x="780" y="20" width="200" height="90" rx="12" class="bx-p"/>
<text x="798" y="52" class="t">Accept and reopen</text>
<text x="798" y="74" class="ts">budget back to draft;</text>
<text x="798" y="92" class="ts">requester emailed</text>
<rect x="780" y="130" width="200" height="90" rx="12" class="bx-r"/>
<text x="798" y="162" class="t">Decline</text>
<text x="798" y="184" class="ts">reason required; stays</text>
<text x="798" y="202" class="ts">approved; requester emailed</text>
<path d="M210,115 H256" class="ar" marker-end="url(#h7)"/>
<path d="M470,115 H516" class="ar" marker-end="url(#h7)"/>
<path d="M730,100 C755,90 755,70 776,66" class="ar" marker-end="url(#h7)"/>
<path d="M730,130 C755,140 755,170 776,174" class="ar" marker-end="url(#h7)"/>
</svg>
<figcaption>An approver or administrator of the department can also reopen directly with <strong>Reopen</strong>, giving a reason; that also answers an open request. One request can be pending at a time. All requests, decisions and reasons stay in the budget's History.</figcaption>
</figure>
<!-- ============================================================ -->
<h2 id="admin">12. Admin: email, logs, backups, upgrades</h2>
<div class="grid2">
<div class="card">
<h3 style="margin-top:0">Email → Sending account</h3>
<ul>
<li>SMTP server, port, security (STARTTLS 587 / SSL 465 / none), username and password</li>
<li>Sender address and name, reply-to, signature</li>
<li>Copy in the Sent folder over IMAP (found automatically, or named)</li>
<li><strong>Test connection</strong> signs in only; <strong>Send test email</strong> sends one</li>
<li>Turning email on adds "Send by email" for everyone, Upper Management included</li>
</ul>
<p class="muted">Gmail already keeps its own copy, so turn the Sent-folder option off there.</p>
</div>
<div class="card">
<h3 style="margin-top:0">Logs</h3>
<ul>
<li><strong>Activity</strong>: sign-ins (and failed ones), every change to budgets and lines, imports, approvals, reopen requests, archiving, deletions, report views, downloads and emails, admin changes. Filter and export to CSV.</li>
<li><strong>Email → Mail log</strong>: who sent what to whom, attachments, delivered or failed, Sent-folder copy saved or not. Export to CSV.</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0">Backups</h3>
<ul>
<li>A database dump every night at <code>BACKUP_TIME</code> into <code>backups/</code>, kept <code>BACKUP_KEEP_DAYS</code> days</li>
<li>An extra one, <code>…_before_upgrade.sql.gz</code>, before every upgrade that changes the database</li>
<li>Restore: <code>docker compose exec backup restore.sh /backups/&lt;file&gt;</code></li>
<li>Copy <code>backups/</code> to another disk or NAS regularly</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0">Upgrading to a new version</h3>
<ol>
<li>Unzip the new version over the app folder, keeping <code>.env</code> and <code>backups/</code></li>
<li>Run <code>docker compose up -d --build</code></li>
</ol>
<p>The api asks for a backup, waits for it, then updates the database. If the backup fails, it does not upgrade. Never run <code>docker compose down -v</code>: <code>-v</code> deletes the data.</p>
</div>
</div>
<!-- ============================================================ -->
<h2 id="signin">13. Signing in, MFA, appearance and picture</h2>
<p>MFA (two-step sign-in, <em>autenticação em dois passos</em>) is optional and each user turns it on for themselves. With MFA on, someone who learns a password still cannot sign in without the user's phone.</p>
<figure>
<svg viewBox="0 0 1000 360" role="img" aria-labelledby="si-t">
<title id="si-t">Sign-in with and without MFA</title>
<defs>
<marker id="h9" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head"/></marker>
<marker id="h9g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head-g"/></marker>
<marker id="h9r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="head-r"/></marker>
</defs>
<rect x="20" y="110" width="180" height="90" rx="12" class="bx-p"/>
<text x="38" y="142" class="t">Email + password</text>
<text x="38" y="164" class="ts">wrong: "invalid</text>
<text x="38" y="182" class="ts">credentials", logged</text>
<rect x="250" y="110" width="160" height="90" rx="12" class="bx-n"/>
<text x="268" y="142" class="t">MFA on?</text>
<text x="268" y="164" class="ts">set per user in</text>
<text x="268" y="182" class="ts">My profile</text>
<rect x="460" y="100" width="260" height="110" rx="12" class="bx-y"/>
<text x="478" y="130" class="t">Verification code</text>
<text x="478" y="152" class="ts">6 digits from the app, changes</text>
<text x="478" y="170" class="ts">every 30 s, works only once;</text>
<text x="478" y="188" class="ts">or a recovery code (xxxx-xxxx)</text>
<rect x="460" y="260" width="260" height="80" rx="12" class="bx-r"/>
<text x="478" y="290" class="t">5 wrong codes in a row</text>
<text x="478" y="312" class="ts">code sign-in locked for 5 minutes</text>
<rect x="770" y="110" width="210" height="90" rx="12" class="bx-g"/>
<text x="788" y="142" class="t">Signed in</text>
<text x="788" y="164" class="ts">session lasts</text>
<text x="788" y="182" class="ts">SESSION_HOURS</text>
<path d="M200,155 H246" class="ar" marker-end="url(#h9)"/>
<path d="M410,155 H456" class="ar" marker-end="url(#h9)"/>
<text x="420" y="146" class="lbl">yes</text>
<path d="M330,110 V50 H875 V106" class="ar-g" marker-end="url(#h9g)"/>
<text x="560" y="42" class="lbl">no MFA: straight in</text>
<path d="M720,155 H766" class="ar-g" marker-end="url(#h9g)"/>
<text x="726" y="146" class="lbl">right</text>
<path d="M590,210 V256" class="ar-r" marker-end="url(#h9r)"/>
<text x="598" y="240" class="lbl">wrong</text>
</svg>
<figcaption>The code step appears only for users who turned MFA on. A code is accepted if it is the current one or the one just before or after, so a phone clock that is slightly off still works.</figcaption>
</figure>
<div class="grid2">
<div class="card">
<h3 style="margin-top:0">Turning MFA on (My profile)</h3>
<ol>
<li>Install an authenticator app on the phone: Microsoft Authenticator, Google Authenticator, Authy, 1Password…</li>
<li>In <strong>My profile</strong>, press <strong>Turn on MFA</strong> and scan the QR code (or type the key shown).</li>
<li>Type the 6-digit code the app shows and press <strong>Confirm and turn on</strong>. MFA is only on after this step.</li>
<li>Save the 8 <strong>recovery codes</strong>. Each one works once, for when the phone is not at hand. They are not shown again; <em>New recovery codes</em> replaces them.</li>
</ol>
<p class="muted">Turning MFA off needs the password and a code.</p>
</div>
<div class="card">
<h3 style="margin-top:0">For administrators</h3>
<ul>
<li><strong>Users</strong> has an MFA column: <em>On</em> or <em>Off</em> for each user.</li>
<li>Someone lost their phone and recovery codes: <strong>Users → Edit → Reset MFA</strong>. They sign in with the password and can turn MFA on again.</li>
<li>MFA secrets are stored encrypted with <code>SECRET_KEY</code>. Keep the same <code>.env</code> when upgrading, or everyone's MFA has to be reset.</li>
<li>The Activity log records MFA turned on or off, resets, wrong codes and recovery codes used.</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0">Light and dark mode</h3>
<p>The round button in the top bar (and on the sign-in page) cycles <strong>Automatic → Light → Dark</strong>. Automatic follows the computer's setting. The same choice is in <strong>My profile → Appearance</strong>, and it is saved with the user, so it follows them to other computers. Reports in PDF and Excel are not affected.</p>
</div>
<div class="card">
<h3 style="margin-top:0">Profile picture</h3>
<p>In <strong>My profile</strong>, <strong>Choose picture</strong> takes a JPG, PNG, GIF or WebP of up to 5 MB. It is cropped to a square from the centre and reduced to 256 px. It shows in the top bar and in the users list; without one, the user's initials are shown. Pictures are stored in the database, so they are in the backups. <strong>Remove</strong> deletes it.</p>
</div>
</div>
<p class="muted" style="margin-top:40px">Budget &amp; Capex · version 7.0.0 · guide updated 29 October 2026.</p>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+32
View File
@@ -0,0 +1,32 @@
{
admin off
}
{$SITE_ADDRESS::80} {
encode zstd gzip
handle /api/* {
reverse_proxy api:8000 {
transport http {
read_timeout 120s
}
}
}
handle {
root * /srv/web
try_files {path} /index.html
file_server
}
header {
X-Content-Type-Options nosniff
Referrer-Policy same-origin
X-Frame-Options SAMEORIGIN
-Server
}
request_body {
max_size 25MB
}
}
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /web
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY index.html vite.config.js ./
COPY src ./src
RUN npm run build
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /web/dist /srv/web
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231f3b63'/><rect x='7' y='16' width='4' height='9' fill='%23fff'/><rect x='14' y='10' width='4' height='15' fill='%23e8632b'/><rect x='21' y='6' width='4' height='19' fill='%23fff'/></svg>" />
<title>Budget &amp; Capex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "budget-capex-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.4",
"vite": "5.4.11"
}
}
+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { api } from './api.js'
import { AuthContext, atLeast } from './auth.js'
import { I18nContext, makeT } from './i18n.js'
import Layout from './components/Layout.jsx'
import Login from './pages/Login.jsx'
import Years from './pages/Years.jsx'
import YearEditor from './pages/YearEditor.jsx'
import Reports from './pages/Reports.jsx'
import Users from './pages/Users.jsx'
import Settings from './pages/Settings.jsx'
import Profile from './pages/Profile.jsx'
import Departments from './pages/Departments.jsx'
import Activity from './pages/Activity.jsx'
import Mail from './pages/Mail.jsx'
const LANG_KEY = 'budget.lang'
const THEME_KEY = 'budget.theme'
const THEMES = ['system', 'light', 'dark']
function storedLang() {
try {
return localStorage.getItem(LANG_KEY) || 'pt'
} catch {
return 'pt'
}
}
function storedTheme() {
try {
return localStorage.getItem(THEME_KEY) || 'system'
} catch {
return 'system'
}
}
export default function App() {
const [user, setUser] = useState(undefined)
const [lang, setLangState] = useState(storedLang)
const [theme, setThemeState] = useState(storedTheme)
useEffect(() => {
api.get('/api/auth/me').then((u) => {
setUser(u)
setLangState(u.language)
if (THEMES.includes(u.theme)) setThemeState(u.theme)
}).catch(() => setUser(null))
const onLogout = () => setUser(null)
window.addEventListener('budget:logout', onLogout)
return () => window.removeEventListener('budget:logout', onLogout)
}, [])
useEffect(() => {
document.documentElement.lang = lang
try {
localStorage.setItem(LANG_KEY, lang)
} catch { /* private mode */ }
}, [lang])
// system: follow the computer; light / dark: always
useEffect(() => {
const root = document.documentElement
if (theme === 'system') delete root.dataset.theme
else root.dataset.theme = theme
try {
localStorage.setItem(THEME_KEY, theme)
} catch { /* private mode */ }
}, [theme])
const setTheme = useCallback((next) => {
setThemeState(next)
if (user) {
setUser((u) => u && { ...u, theme: next })
api.put('/api/auth/me', { theme: next }).catch(() => {})
}
}, [user])
const setLang = useCallback((next) => {
setLangState(next)
if (user) api.put('/api/auth/me', { language: next }).catch(() => {})
}, [user])
const t = useMemo(() => makeT(lang), [lang])
const auth = useMemo(() => ({ user, setUser, lang, setLang, theme, setTheme }), [user, lang, setLang, theme, setTheme])
if (user === undefined) return null
return (
<I18nContext.Provider value={t}>
<AuthContext.Provider value={auth}>
{!user ? (
<Login onLogin={(u) => { setUser(u); setLangState(u.language); if (THEMES.includes(u.theme)) setThemeState(u.theme) }} />
) : (
<Layout>
<Routes>
<Route path="/" element={<Years />} />
<Route path="/years/:id" element={<YearEditor />} />
<Route path="/reports" element={<Reports />} />
<Route path="/profile" element={<Profile />} />
{atLeast(user, 'admin') && <Route path="/users" element={<Users />} />}
{atLeast(user, 'approver') && <Route path="/settings" element={<Settings />} />}
{atLeast(user, 'admin') && <Route path="/departments" element={<Departments />} />}
{atLeast(user, 'admin') && <Route path="/activity" element={<Activity />} />}
{atLeast(user, 'admin') && <Route path="/mail" element={<Mail />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
)}
</AuthContext.Provider>
</I18nContext.Provider>
)
}
+34
View File
@@ -0,0 +1,34 @@
export class ApiError extends Error {
constructor(status, code) {
super(code)
this.status = status
this.code = code
}
}
async function request(method, url, body) {
const opts = { method, credentials: 'same-origin', headers: { 'X-Requested-With': 'budget-app' } }
if (body instanceof FormData) {
opts.body = body
} else if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
const res = await fetch(url, opts)
if (res.status === 401 && !url.endsWith('/auth/login')) {
window.dispatchEvent(new Event('budget:logout'))
}
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
if (!res.ok) {
const detail = data?.detail
throw new ApiError(res.status, typeof detail === 'string' ? detail : JSON.stringify(detail))
}
return data
}
export const api = {
get: (url) => request('GET', url),
post: (url, body) => request('POST', url, body ?? {}),
put: (url, body) => request('PUT', url, body),
del: (url) => request('DELETE', url),
}
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from 'react'
// same order as the server: Upper Management ('management') only views, downloads and emails
export const ROLES = ['viewer', 'management', 'editor', 'approver', 'admin']
export const AuthContext = createContext(null)
export const useAuth = () => useContext(AuthContext)
export const atLeast = (user, role) => !!user && ROLES.indexOf(user.role) >= ROLES.indexOf(role)
export const THEMES = ['system', 'light', 'dark']
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal } from './ui.jsx'
import { SendResult } from '../pages/Mail.jsx'
const CHOICES = [['cost-report', 'pdf'], ['cost-report', 'xlsx'], ['budget', 'pdf'], ['budget', 'xlsx'], ['workbook', 'xlsx']]
// Sends the chosen reports of one budget, with the language and site picked on the Reports page.
export default function EmailDialog({ year, initial, reportLang, siteId, siteName, includeEmpty, onClose }) {
const t = useT()
const { user } = useAuth()
const [to, setTo] = useState('')
const [cc, setCc] = useState('')
const [copyMe, setCopyMe] = useState(false)
const [subject, setSubject] = useState(`${year.department} ${year.year} — ${year.title}`)
const [message, setMessage] = useState(t('email_default_message', { title: year.title, dept: year.department, name: user.name }))
const [picked, setPicked] = useState(() => new Set(initial ? [initial.join('.')] : ['cost-report.pdf']))
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(null)
const toggle = (key) => {
const next = new Set(picked)
next.has(key) ? next.delete(key) : next.add(key)
setPicked(next)
}
const send = async () => {
setBusy(true); setError(null)
try {
const log = await api.post(`/api/years/${year.id}/email`, {
to: [to], cc: cc ? [cc] : [], copy_me: copyMe, subject, message, lang: reportLang,
site_id: siteId ? Number(siteId) : null, include_empty: includeEmpty,
reports: [...picked].map((k) => { const [kind, fmt] = k.split('.'); return { kind, fmt } }),
})
setDone(log)
} catch (e) { setError(e) } finally { setBusy(false) }
}
const name = (kind) => (kind === 'budget' ? t('report_budget') : kind === 'workbook' ? t('budget_file') : t('report_cost'))
return (
<Modal title={t('email_title')} onClose={onClose} footer={done
? <button className="primary" onClick={onClose}>{t('close')}</button>
: <>
<button onClick={onClose}>{t('cancel')}</button>
<button className="primary" disabled={busy || !to.trim() || picked.size === 0} onClick={send}>{busy ? t('email_sending') : t('email_send')}</button>
</>}>
{done ? <SendResult log={done} /> : <>
<p className="muted small">{year.department} · {year.year} · {t('email_options', { lang: reportLang === 'en' ? 'English' : 'Português', site: siteName || t('all_sites') })}</p>
<Field label={t('email_to')} hint={t('email_to_hint')}><input value={to} onChange={(e) => setTo(e.target.value)} placeholder="nome@empresa.pt" autoFocus /></Field>
<Field label={t('email_cc')}><input value={cc} onChange={(e) => setCc(e.target.value)} /></Field>
<label className="check"><input type="checkbox" checked={copyMe} onChange={(e) => setCopyMe(e.target.checked)} /> {t('email_copy_me')} <span className="muted small">({user.email})</span></label>
<Field label={t('email_subject')}><input value={subject} onChange={(e) => setSubject(e.target.value)} maxLength={300} /></Field>
<Field label={t('email_message')}><textarea rows={6} value={message} onChange={(e) => setMessage(e.target.value)} /></Field>
<div className="field">
<span className="field-label">{t('email_attachments')}</span>
<div className="choices">
{CHOICES.map(([kind, fmt]) => (
<label key={kind + fmt} className="check"><input type="checkbox" checked={picked.has(`${kind}.${fmt}`)} onChange={() => toggle(`${kind}.${fmt}`)} /> {name(kind)}{kind !== 'workbook' && ` · ${fmt === 'pdf' ? 'PDF' : 'Excel'}`}</label>
))}
</div>
</div>
<ErrorNote error={error} />
</>}
</Modal>
)
}
@@ -0,0 +1,44 @@
import { NavLink } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { Avatar, ThemeButton } from './ui.jsx'
export default function Layout({ children }) {
const t = useT()
const { user, setUser, lang, setLang } = useAuth()
const logout = async () => {
await api.post('/api/auth/logout').catch(() => {})
setUser(null)
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="logo" aria-hidden="true"><i /><i /><i /></span>
{t('app_name')}
</div>
<nav>
<NavLink to="/" end>{t('nav_years')}</NavLink>
<NavLink to="/reports">{t('nav_reports')}</NavLink>
{atLeast(user, 'admin') && <NavLink to="/departments">{t('nav_departments')}</NavLink>}
{atLeast(user, 'approver') && <NavLink to="/settings">{atLeast(user, 'admin') ? t('nav_settings') : t('nav_categories')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/users">{t('nav_users')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/activity">{t('nav_activity')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/mail">{t('nav_mail')}</NavLink>}
</nav>
<div className="userbox">
<div className="lang" role="group" aria-label={t('language')}>
{['pt', 'en'].map((l) => (
<button key={l} className={l === lang ? 'on' : ''} onClick={() => setLang(l)}>{l.toUpperCase()}</button>
))}
</div>
<ThemeButton />
<NavLink to="/profile" className="me" title={user.name}><Avatar user={user} /><span className="me-name">{user.name}{user.department && <span className="muted small"> · {user.department}</span>}</span></NavLink>
<button className="link" onClick={logout}>{t('sign_out')}</button>
</div>
</header>
<main className="content">{children}</main>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect } from 'react'
import { THEMES, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
export function Modal({ title, onClose, children, footer, wide }) {
useEffect(() => {
const onKey = (e) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className={'modal' + (wide ? ' wide' : '')} role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-head">
<h2>{title}</h2>
<button className="icon" onClick={onClose} aria-label="close">×</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
)
}
export function Field({ label, hint, children }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
{hint && <span className="hint">{hint}</span>}
</label>
)
}
export function ErrorNote({ error }) {
const t = useT()
if (!error) return null
return <div className="error" role="alert">{t.error(error.code || error.message)}</div>
}
export function StatusBadge({ status }) {
const t = useT()
return <span className={`badge st-${status}`}>{t(`status_${status}`)}</span>
}
export function Change({ value, pctValue, fmt }) {
const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''
return <span className={cls}>{fmt(value, pctValue)}</span>
}
// a user's picture, or their initials when they have none
export function Avatar({ user, large }) {
const cls = 'avatar' + (large ? ' lg' : '')
if (user?.avatar_url) return <img className={cls} src={user.avatar_url} alt="" />
const initials = (user?.name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('')
return <span className={cls} aria-hidden="true">{initials}</span>
}
export function ThemeButton() {
const t = useT()
const { theme, setTheme } = useAuth()
const next = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]
return (
<button type="button" className="icon theme-btn" onClick={() => setTheme(next)}
title={`${t('theme')}: ${t('theme_' + theme)} · ${t('theme_switch')}`} aria-label={`${t('theme')}: ${t('theme_' + theme)}`}>
<ThemeIcon theme={theme} />
</button>
)
}
function ThemeIcon({ theme }) {
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2,
strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }
if (theme === 'light') return (
<svg {...common}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
)
if (theme === 'dark') return <svg {...common}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" /></svg>
}
+25
View File
@@ -0,0 +1,25 @@
const cache = {}
function nf(lang, opts) {
const key = lang + JSON.stringify(opts)
// 'always' groups 4-digit amounts too (pt-PT would otherwise print 9888,00 but 19 888,00)
if (!cache[key]) cache[key] = new Intl.NumberFormat(lang === 'en' ? 'en-IE' : 'pt-PT', { useGrouping: 'always', ...opts })
return cache[key]
}
export const eur = (v, lang, digits = 2) =>
v == null ? '—' : nf(lang, { style: 'currency', currency: 'EUR', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v)
export const num = (v, lang, digits = 2) =>
v == null ? '—' : nf(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v)
export const pct = (v, lang) =>
v == null ? '—' : (v > 0 ? '+' : '') + nf(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v) + '%'
export const signedEur = (v, lang) => (v == null ? '—' : (v > 0 ? '+' : '') + eur(v, lang))
export const dateTime = (iso, lang) =>
iso ? new Date(iso).toLocaleString(lang === 'en' ? 'en-GB' : 'pt-PT', { dateStyle: 'short', timeStyle: 'short' }) : ''
// Shows a stored expression in the user's decimal style: "839.67*12" -> "839,67*12" in Portuguese.
export const exprForDisplay = (expr, lang) => (lang === 'pt' ? String(expr ?? '').replace(/\./g, ',') : String(expr ?? ''))
+865
View File
@@ -0,0 +1,865 @@
import { createContext, useContext } from 'react'
const pt = {
app_name: 'Orçamento & Capex',
login_title: 'Entrar',
email: 'Email',
password: 'Palavra-passe',
sign_in: 'Entrar',
sign_out: 'Sair',
invalid_credentials: 'Email ou palavra-passe incorretos.',
nav_years: 'Orçamentos',
nav_reports: 'Relatórios',
nav_users: 'Utilizadores',
nav_settings: 'Categorias e unidades',
nav_profile: 'O meu perfil',
years_title: 'Orçamentos anuais',
new_year: 'Novo ano',
import_xlsx: 'Importar Excel',
open: 'Abrir',
status_draft: 'Rascunho',
status_in_review: 'Em revisão',
status_approved: 'Aprovado',
prior_actual: '{prior} real',
base: '{year} sem ajuste',
adjusted: '{year} com ajuste',
change: 'Variação',
change_pct: 'Variação %',
total_costs: 'Total de custos',
revenue: 'Receitas',
no_years: 'Ainda não há orçamentos. Crie um ano novo ou importe o ficheiro Excel.',
year: 'Ano',
title: 'Título',
default_rate: 'Inflação + ajustes (%)',
copy_from: 'Copiar linhas de',
copy_none: 'Começar vazio',
copy_hint: 'O orçamento ajustado do ano copiado passa a ser o valor real e a base do novo ano. Substitua depois pelos valores reais.',
create: 'Criar',
cancel: 'Cancelar',
save: 'Guardar',
close: 'Fechar',
delete: 'Apagar',
edit: 'Editar',
confirm_delete: 'Apagar "{name}"?',
file: 'Ficheiro',
import_year_hint: 'Deixe vazio para usar o ano indicado no ficheiro.',
replace_existing: 'Substituir o ano se já existir (só rascunhos)',
import_done: 'Importadas {n} linhas para {year}.',
import: 'Importar',
search: 'Pesquisar linhas…',
all_sites: 'Todas as unidades',
group: 'Grupo',
site: 'Unidade',
line: 'Linha',
name: 'Nome',
description: 'Descrição',
remarks: 'Observações',
category: 'Categoria',
adjustment: 'Ajuste',
adj_year_rate: 'Taxa do ano',
adj_percent: '%',
adj_factor: 'Fator ×',
adj_none: 'Sem ajuste',
adj_reason: 'Motivo do ajuste',
months: 'Meses',
monthly_even: 'Dividir por 12',
monthly_manual: 'Valores mensais',
monthly_selected: 'Dividir pelos meses escolhidos',
monthly_pick: 'Meses',
monthly_pick_hint: 'O valor com ajuste é dividido em partes iguais por {n} meses ({months}). Os outros meses ficam a zero.',
monthly_pick_none: 'Escolha os meses em que o valor é gasto.',
quarter: '{n}.º trim.',
half: '{n}.º sem.',
clear: 'Limpar',
monthly_hint_manual: 'O total do ano é a soma dos meses.',
add_line: 'Adicionar linha',
show_empty: 'Mostrar categorias vazias',
lines_count: '{n} linhas',
lines_one: '1 linha',
submit_review: 'Enviar para revisão',
approve: 'Aprovar',
return_draft: 'Devolver para rascunho',
reopen: 'Reabrir',
locked_hint: 'Este orçamento está {status} e não pode ser editado.',
history: 'Histórico',
submitted_by: 'Enviado por {name}',
approved_by: 'Aprovado por {name}',
expr_hint: 'Pode escrever cálculos, ex.: 12*839,67+3292,56',
invalid_expression: 'Valor inválido',
reports_title: 'Relatórios',
report_cost: 'Capex e Custos',
report_cost_desc: 'Relatório de custos por categoria: real do ano anterior vs. ano com ajuste, com gráficos.',
report_budget: 'Orçamento mensal',
report_budget_desc: 'Orçamento por categoria e linha, de janeiro a dezembro, como a folha Budget IT. O Excel inclui também a folha de dados.',
view_online: 'Ver online',
download_pdf: 'PDF',
download_xlsx: 'Excel',
language: 'Idioma',
include_empty: 'Incluir categorias vazias',
users_title: 'Utilizadores',
new_user: 'Novo utilizador',
role: 'Perfil',
role_viewer: 'Leitura',
role_editor: 'Editor',
role_approver: 'Aprovador',
role_admin: 'Administrador',
role_management: 'Gestão de Topo',
management_scope: 'Gestão de Topo: vê todos os departamentos. Pode consultar orçamentos e relatórios, descarregá-los e enviá-los por e-mail.',
roles_hint: 'Leitura: ver e exportar. Gestão de Topo: ver, descarregar e enviar por e-mail orçamentos e relatórios de todos os departamentos, sem alterar nada. Editor: editar rascunhos do seu departamento e enviar para revisão. Aprovador: também aprova e gere as categorias do departamento. Administrador: tudo, incluindo departamentos, fluxos e utilizadores.',
active: 'Ativo',
inactive: 'Inativo',
new_password: 'Nova palavra-passe',
password_optional: 'Deixe vazio para manter',
current_password: 'Palavra-passe atual',
categories: 'Categorias',
sites: 'Unidades',
name_en: 'Nome (inglês)',
name_pt: 'Nome (português)',
section: 'Secção',
section_cost: 'Custo',
section_revenue: 'Receita',
order: 'Ordem',
new_category: 'Nova categoria',
new_site: 'Nova unidade',
saved: 'Guardado.',
profile_title: 'O meu perfil',
error: 'Ocorreu um erro: {msg}',
when: 'Quando',
who: 'Quem',
what: 'O quê',
nothing_yet: 'Sem registos.',
totals: 'Totais',
details: 'Detalhes',
nav_departments: 'Departamentos',
nav_categories: 'Categorias',
department: 'Departamento',
departments: 'Departamentos',
all_departments: 'Todos os departamentos',
new_department: 'Novo departamento',
categories_from: 'Categorias iniciais',
categories_defaults: 'Plano de contas padrão',
categories_none: 'Nenhuma',
categories_copy: 'Copiar de {name}',
copy_categories: 'Copiar categorias',
copy_categories_done: 'Categorias copiadas.',
workflow: 'Fluxo de aprovação',
workflow_hint: 'Depois de enviado, o orçamento passa por estes passos por ordem. Só o departamento do orçamento o pode editar; os outros departamentos só podem ver.',
workflow_default: 'Sem passos definidos: um passo, aprovado por qualquer aprovador do departamento.',
step_name: 'Nome do passo',
approver_mode: 'Quem aprova',
mode_role: 'Aprovadores do departamento',
mode_users: 'Utilizadores escolhidos',
add_step: 'Adicionar passo',
save_workflow: 'Guardar fluxo',
move_up: 'Subir',
move_down: 'Descer',
step_of: 'Passo {n} de {total}: {name}',
waiting_for: 'A aguardar: {name}',
view_only_dept: 'Só o departamento {dept} pode alterar e aprovar este orçamento.',
no_department: 'Sem departamento',
template: 'Modelo Excel',
template_title: 'Descarregar modelo Excel',
template_hint: 'Preencha o modelo no Excel e importe-o em Orçamentos > Importar Excel.',
prefill_from: 'Pré-preencher com as linhas de',
prefill_none: 'Modelo em branco',
download: 'Descarregar',
year_rate_pct: 'Taxa do ano (%)',
user_department_hint: 'Os utilizadores só podem editar e aprovar orçamentos do seu departamento. A Gestão de Topo vê todos os departamentos, com ou sem departamento.',
nav_activity: "Atividade",
search_log: "Pesquisar…",
nav_mail: "Email",
delete_budget: "Apagar orçamento",
delete_budget_text: "Vai apagar o orçamento {dept} {year} e todas as suas linhas. Os relatórios deste orçamento deixam de estar disponíveis para todos. Não é possível desfazer. O registo de atividade guarda quem o apagou.",
delete_budget_confirm: "Escreva {year} para confirmar",
budget_deleted: "O orçamento {dept} {year} foi apagado.",
send_email: "Enviar por email",
email_title: "Enviar relatórios por email",
email_to: "Para",
email_cc: "Cc",
email_to_hint: "Separe vários endereços com vírgulas.",
email_copy_me: "Enviar-me uma cópia",
email_subject: "Assunto",
email_message: "Mensagem",
email_attachments: "Anexos",
email_send: "Enviar",
email_sending: "A enviar…",
email_options: "Relatórios em {lang} · {site}",
email_default_message: "Bom dia,\n\nSegue em anexo o {title} ({dept}).\n\nCumprimentos,\n{name}",
email_sent: "Email enviado para {to}.",
email_copy_saved: "Cópia guardada na pasta \"{folder}\".",
email_copy_failed: "O email foi enviado, mas não foi possível guardar a cópia na pasta Enviados ({detail}).",
email_not_configured: "O envio por email ainda não está configurado.",
email_configure: "Configurar email",
mail_title: "Email",
mail_tab_account: "Conta de envio",
mail_tab_log: "Registo de emails",
mail_enabled: "Envio por email ativo",
mail_enabled_hint: "Quando ativo, todos os utilizadores podem enviar relatórios por email a partir da página Relatórios.",
mail_smtp: "Servidor de envio (SMTP)",
host: "Servidor",
port: "Porta",
security: "Segurança",
security_starttls: "STARTTLS (normalmente porta 587)",
security_ssl: "SSL/TLS (normalmente 465 no SMTP, 993 no IMAP)",
security_none: "Sem encriptação (só em redes de confiança)",
username: "Utilizador",
password_keep_hint: "Deixe em branco para manter a palavra-passe guardada.",
password_saved: "guardada",
mail_sender: "Remetente",
from_address: "Endereço do remetente",
from_name: "Nome do remetente",
reply_to: "Responder para (opcional)",
signature: "Assinatura (opcional)",
mail_sent_copy: "Cópia na pasta Enviados (IMAP)",
save_to_sent: "Guardar uma cópia de cada email enviado na pasta Enviados do servidor",
save_to_sent_hint: "O Gmail já guarda sozinho uma cópia dos emails enviados por SMTP. Com o Gmail, desligue esta opção para não ficar com cópias repetidas.",
imap_host_hint: "Vazio: usa o mesmo servidor do SMTP.",
imap_same_login: "Usar o mesmo utilizador e palavra-passe do SMTP",
sent_folder: "Pasta Enviados",
sent_folder_hint: "Vazio: é encontrada automaticamente (Sent, Enviados, Itens Enviados…).",
mail_check: "Testar ligação",
mail_test_send: "Enviar email de teste",
mail_test_to: "Endereço para o teste",
mail_check_smtp: "SMTP: {result}",
mail_check_imap: "IMAP: {result}",
mail_check_folder: "Pasta Enviados: \"{folder}\"",
ok: "OK",
mail_saved: "Definições guardadas.",
mail_save_first: "Guarde as definições antes de testar.",
last_changed: "Alterado por {name} · {when}",
log_sent: "Enviado",
log_failed: "Falhou",
copy_saved: "Cópia guardada",
copy_failed: "Cópia falhou",
copy_off: "Sem cópia",
from_date: "De",
to_date: "Até",
all_users: "Todos os utilizadores",
all_statuses: "Todos os estados",
all_areas: "Todas as áreas",
all_actions: "Todas as ações",
export_csv: "Exportar CSV",
results_count: "{n} registos",
prev: "Anterior",
next: "Seguinte",
details: "Detalhes",
activity_title: "Registo de atividade",
activity_hint: "Tudo o que os utilizadores fazem: entradas na aplicação, alterações a orçamentos e linhas, aprovações, importações, relatórios vistos, descarregados e enviados, e alterações de administração.",
mail_log_hint: "Todos os emails enviados pela aplicação: quem enviou, para quem, o quê e se a cópia ficou na pasta Enviados.",
col_area: "Área",
col_action: "Ação",
col_budget: "Orçamento",
col_to: "Para",
col_subject: "Assunto",
col_status: "Estado",
col_copy: "Pasta Enviados",
col_attachments: "Anexos",
col_from: "De",
deleted: "apagado",
test_email: "teste",
area_user: "Utilizador",
area_year: "Orçamento",
area_line: "Linha",
area_report: "Relatório",
area_mail: "Email",
area_mail_settings: "Definições de email",
area_category: "Categoria",
area_site: "Unidade",
area_department: "Departamento",
area_template: "Modelo Excel",
act_create: "criou",
act_update: "alterou",
act_delete: "apagou",
act_login: "entrou",
act_logout: "saiu",
act_login_failed: "falhou a entrada",
act_password_changed: "mudou a palavra-passe",
act_import: "importou",
act_submit: "submeteu",
act_approve: "aprovou",
act_return: "devolveu",
act_reopen: "reabriu",
act_view: "viu",
act_download: "descarregou",
act_send: "enviou",
act_send_failed: "envio falhou",
act_check: "testou a ligação",
act_workflow: "mudou o fluxo",
act_copy_categories: "copiou categorias",
upload_excel: "Carregar Excel",
request_reopen: "Pedir reabertura",
send_request: "Enviar pedido",
archived_note: "Versão arquivada em {when} por {name}: foi substituída por um ficheiro carregado. Está bloqueada e já não pode ser alterada.",
reopen_pending: "{name} pediu a reabertura deste orçamento ({when}):",
reopen_accept: "Aceitar e reabrir",
reopen_decline: "Recusar",
loaded_from: "Carregado do ficheiro {file}",
reopened_notice: "O orçamento foi reaberto e está de novo em rascunho.",
reopen_sent_notice: "Pedido enviado. Os aprovadores do departamento vão decidir.",
budget_file: "Ficheiro do orçamento (Excel editável)",
budget_file_hint: "Altere e volte a carregar",
reopen_direct_hint: "O orçamento volta a rascunho. O motivo fica no histórico.",
reopen_request_hint: "Os aprovadores do departamento recebem o pedido e decidem se o orçamento volta a rascunho.",
decline_reason: "Motivo da recusa",
reopen_reason: "Motivo",
reason_min: "Obrigatório (pelo menos 5 caracteres).",
reopen_requests: "Pedidos de reabertura",
changes: "Alterações",
req_pending: "Pendente",
req_accepted: "Aceite",
req_declined: "Recusado",
import_archived: "A versão anterior foi guardada como \"{title}\" e bloqueada.",
file_exists_title: "O ficheiro já existe",
file_exists_same: "O ficheiro {file} já existe na aplicação ({dept} {year}).",
file_exists_budget: "Já existe um orçamento {dept} {year} na aplicação.",
file_exists_replace: "Os dados atuais vão ser substituídos pelos do ficheiro. A versão atual não é apagada: fica guardada com a data e hora no nome e bloqueada para alterações.",
current_version: "Versão atual",
renamed_to: "Passa a chamar-se",
locked: "bloqueada",
file_exists_locked: "Não é possível substituir: o orçamento está {status}. Peça a reabertura primeiro.",
reopen_requested_badge: "Reabertura pedida",
show_archived: "Mostrar versões arquivadas",
hide_archived: "Esconder versões arquivadas",
no_archived: "Não há versões arquivadas.",
archived_when: "Arquivado",
archived_badge: "Arquivado",
act_archive: "arquivou",
act_reopen_requested: "pediu reabertura",
act_reopen_declined: "recusou reabertura",
theme: 'Aspeto',
theme_system: 'Automático (do computador)',
theme_light: 'Claro',
theme_dark: 'Escuro',
theme_switch: 'Mudar entre claro, escuro e automático',
avatar: 'Fotografia',
avatar_upload: 'Escolher fotografia',
avatar_remove: 'Remover',
avatar_hint: 'JPG, PNG, GIF ou WebP até 5 MB. É recortada em quadrado e reduzida a 256 px.',
security: 'Segurança',
change_password: 'Mudar a palavra-passe',
mfa: 'Autenticação em dois passos (MFA)',
mfa_short: 'MFA',
mfa_on: 'Ativa',
mfa_off: 'Inativa',
mfa_on_since: 'Ativa desde {when}. Restam {n} códigos de recuperação.',
mfa_off_text: 'Além da palavra-passe, pede um código de 6 dígitos de uma aplicação no telemóvel (Microsoft Authenticator, Google Authenticator, Authy…).',
mfa_setup: 'Ativar MFA',
mfa_disable: 'Desativar MFA',
mfa_new_codes: 'Novos códigos de recuperação',
mfa_step1: '1. Abra a aplicação de autenticação e leia este código QR.',
mfa_manual: 'Ou escreva esta chave na aplicação:',
mfa_step2: '2. Escreva o código de 6 dígitos que a aplicação mostra.',
mfa_code: 'Código',
mfa_confirm: 'Confirmar e ativar',
mfa_codes_title: 'Guarde os códigos de recuperação',
mfa_codes_text: 'Cada código serve uma vez, se ficar sem o telemóvel. Guarde-os num local seguro: não voltam a ser mostrados.',
mfa_codes_copy: 'Copiar',
mfa_codes_done: 'Já guardei os códigos',
mfa_disable_text: 'Para desativar, escreva a palavra-passe e um código da aplicação (ou um código de recuperação).',
mfa_new_codes_text: 'Os códigos antigos deixam de funcionar. Escreva um código da aplicação para continuar.',
mfa_login_title: 'Código de verificação',
mfa_login_text: 'Abra a aplicação de autenticação e escreva o código de 6 dígitos. Sem o telemóvel, use um código de recuperação.',
mfa_back: 'Voltar',
mfa_verify: 'Verificar',
mfa_reset: 'Repor MFA',
mfa_reset_text: 'Desativa o MFA deste utilizador (por exemplo, se perdeu o telemóvel). Pode voltar a ativá-lo no seu perfil.',
mfa_reset_done: 'MFA desativado para este utilizador.',
act_mfa_enabled: 'ativou o MFA',
act_mfa_disabled: 'desativou o MFA',
act_mfa_reset: 'repôs o MFA',
act_mfa_failed: 'errou o código MFA',
act_mfa_recovery_used: 'usou um código de recuperação',
act_mfa_recovery_renewed: 'gerou novos códigos de recuperação',
act_avatar_changed: 'mudou a fotografia',
act_avatar_removed: 'removeu a fotografia',
errors: {
invalid_mfa_code: 'Código inválido ou já usado. Tente o código seguinte da aplicação.',
mfa_locked: 'Demasiados códigos errados. Aguarde 5 minutos e tente de novo.',
mfa_setup_first: 'Comece de novo a ativação do MFA.',
mfa_already_on: 'O MFA já está ativo.',
mfa_off: 'O MFA não está ativo.',
not_an_image: 'O ficheiro não é uma imagem.',
image_too_large: 'A imagem tem mais de 5 MB.',
reason_required: "Indique o motivo (pelo menos 5 caracteres).",
reopen_already_requested: "Já há um pedido de reabertura pendente.",
year_not_draft: "O orçamento não está em rascunho. Peça a reabertura primeiro.",
mail_not_configured: "O envio por email ainda não está configurado. Peça a um administrador.",
mail_incomplete: "Preencha o servidor SMTP e o endereço do remetente.",
invalid_address: "Endereço de email inválido.",
no_recipients: "Indique pelo menos um destinatário.",
too_many_recipients: "Demasiados destinatários (máximo 25).",
no_attachments: "Escolha pelo menos um relatório para anexar.",
smtp_auth: "O servidor de email recusou o utilizador ou a palavra-passe.",
smtp_connect: "Não foi possível ligar ao servidor de email. Verifique o servidor e a porta.",
smtp_tls: "Falhou a ligação segura (TLS) ao servidor de email. Verifique a opção de segurança.",
smtp_recipients: "O servidor recusou um ou mais destinatários.",
smtp_error: "O servidor de email devolveu um erro.",
invalid_message: "Não foi possível montar o email.",
password_unreadable: "A palavra-passe guardada já não pode ser lida (a SECRET_KEY mudou). Introduza-a de novo.",
invalid_security: "Opção de segurança inválida.",
no_months_selected: 'Escolha pelo menos um mês.',
invalid_date: "Data inválida.",
file_too_large: "O ficheiro é demasiado grande.",
invalid_department: 'Escolha um departamento válido.',
workflow_in_use: 'Há orçamentos deste departamento em revisão. Aprove-os ou devolva-os antes de mudar o fluxo.',
step_needs_approvers: 'Escolha pelo menos um utilizador para cada passo com utilizadores escolhidos.',
invalid_step: 'Cada passo precisa de um nome.',
template_header_missing: 'Não foi encontrada a linha de cabeçalhos do modelo.',
invalid_transition: 'O orçamento mudou entretanto. Atualize a página.',
year_exists: 'Esse ano já existe.',
year_not_draft: 'O orçamento não está em rascunho.',
missing_fields: 'Preencha os campos obrigatórios.',
not_an_xlsx: 'O ficheiro não é um Excel válido (.xlsx).',
missing_sheets: 'O ficheiro não tem as folhas "Sheet1" e "Budget IT".',
year_not_found: 'Não foi possível detetar o ano. Indique-o.',
email_taken: 'Esse email já está registado.',
password_too_short: 'A palavra-passe precisa de pelo menos 8 caracteres.',
wrong_password: 'A palavra-passe atual está errada.',
category_in_use: 'A categoria tem linhas e não pode ser apagada. Desative-a.',
name_taken: 'Esse nome já existe.',
forbidden: 'Não tem permissão para isto.',
cannot_demote_self: 'Não pode retirar o seu próprio acesso de administrador.',
},
}
const en = {
app_name: 'Budget & Capex',
login_title: 'Sign in',
email: 'Email',
password: 'Password',
sign_in: 'Sign in',
sign_out: 'Sign out',
invalid_credentials: 'Wrong email or password.',
nav_years: 'Budgets',
nav_reports: 'Reports',
nav_users: 'Users',
nav_settings: 'Categories and sites',
nav_profile: 'My profile',
years_title: 'Yearly budgets',
new_year: 'New year',
import_xlsx: 'Import Excel',
open: 'Open',
status_draft: 'Draft',
status_in_review: 'In review',
status_approved: 'Approved',
prior_actual: '{prior} actual',
base: '{year} without adjustment',
adjusted: '{year} with adjustment',
change: 'Change',
change_pct: 'Change %',
total_costs: 'Total costs',
revenue: 'Revenue',
no_years: 'No budgets yet. Create a new year or import the Excel file.',
year: 'Year',
title: 'Title',
default_rate: 'Inflation + adjustments (%)',
copy_from: 'Copy lines from',
copy_none: 'Start empty',
copy_hint: "The copied year's adjusted budget becomes the new year's actual and base. Replace them with real figures afterwards.",
create: 'Create',
cancel: 'Cancel',
save: 'Save',
close: 'Close',
delete: 'Delete',
edit: 'Edit',
confirm_delete: 'Delete "{name}"?',
file: 'File',
import_year_hint: 'Leave empty to use the year found in the file.',
replace_existing: 'Replace the year if it exists (drafts only)',
import_done: 'Imported {n} lines into {year}.',
import: 'Import',
search: 'Search lines…',
all_sites: 'All sites',
group: 'Group',
site: 'Site',
line: 'Line',
name: 'Name',
description: 'Description',
remarks: 'Remarks',
category: 'Category',
adjustment: 'Adjustment',
adj_year_rate: 'Year rate',
adj_percent: '%',
adj_factor: 'Factor ×',
adj_none: 'No adjustment',
adj_reason: 'Reason for adjustment',
months: 'Months',
monthly_even: 'Split over 12',
monthly_manual: 'Monthly amounts',
monthly_selected: 'Split over chosen months',
monthly_pick: 'Months',
monthly_pick_hint: 'The adjusted amount is split equally over {n} months ({months}). The other months stay at zero.',
monthly_pick_none: 'Choose the months the amount is spent in.',
quarter: 'Q{n}',
half: 'H{n}',
clear: 'Clear',
monthly_hint_manual: 'The year total is the sum of the months.',
add_line: 'Add line',
show_empty: 'Show empty categories',
lines_count: '{n} lines',
lines_one: '1 line',
submit_review: 'Submit for review',
approve: 'Approve',
return_draft: 'Return to draft',
reopen: 'Reopen',
locked_hint: 'This budget is {status} and cannot be edited.',
history: 'History',
submitted_by: 'Submitted by {name}',
approved_by: 'Approved by {name}',
expr_hint: 'You can type calculations, e.g. 12*839.67+3292.56',
invalid_expression: 'Invalid value',
reports_title: 'Reports',
report_cost: 'Capex and Costs',
report_cost_desc: "Cost report by category: last year's actual vs. the year with adjustment, with charts.",
report_budget: 'Monthly budget',
report_budget_desc: 'Budget by category and line, January to December, like the Budget IT sheet. The Excel file also includes the inputs sheet.',
view_online: 'View online',
download_pdf: 'PDF',
download_xlsx: 'Excel',
language: 'Language',
include_empty: 'Include empty categories',
users_title: 'Users',
new_user: 'New user',
role: 'Role',
role_viewer: 'Viewer',
role_editor: 'Editor',
role_approver: 'Approver',
role_admin: 'Administrator',
role_management: 'Upper Management',
management_scope: 'Upper Management: you see every department. You can view budgets and reports, download them and send them by email.',
roles_hint: "Viewer: view and export. Upper Management: view, download and email budgets and reports of every department, without changing anything. Editor: edit their department's drafts and submit them for review. Approver: can also approve and manage the department's categories. Administrator: everything, including departments, workflows and users.",
active: 'Active',
inactive: 'Inactive',
new_password: 'New password',
password_optional: 'Leave empty to keep',
current_password: 'Current password',
categories: 'Categories',
sites: 'Sites',
name_en: 'Name (English)',
name_pt: 'Name (Portuguese)',
section: 'Section',
section_cost: 'Cost',
section_revenue: 'Revenue',
order: 'Order',
new_category: 'New category',
new_site: 'New site',
saved: 'Saved.',
profile_title: 'My profile',
error: 'Something went wrong: {msg}',
when: 'When',
who: 'Who',
what: 'What',
nothing_yet: 'Nothing yet.',
totals: 'Totals',
details: 'Details',
nav_departments: 'Departments',
nav_categories: 'Categories',
department: 'Department',
departments: 'Departments',
all_departments: 'All departments',
new_department: 'New department',
categories_from: 'Starting categories',
categories_defaults: 'Default chart of accounts',
categories_none: 'None',
categories_copy: 'Copy from {name}',
copy_categories: 'Copy categories',
copy_categories_done: 'Categories copied.',
workflow: 'Approval workflow',
workflow_hint: "Once submitted, the budget goes through these steps in order. Only the budget's department can edit it; other departments can only view.",
workflow_default: 'No steps set: one step, approved by any approver of the department.',
step_name: 'Step name',
approver_mode: 'Who approves',
mode_role: 'Department approvers',
mode_users: 'Selected users',
add_step: 'Add step',
save_workflow: 'Save workflow',
move_up: 'Move up',
move_down: 'Move down',
step_of: 'Step {n} of {total}: {name}',
waiting_for: 'Waiting for: {name}',
view_only_dept: 'Only the {dept} department can change and approve this budget.',
no_department: 'No department',
template: 'Excel template',
template_title: 'Download Excel template',
template_hint: 'Fill in the template in Excel and import it under Budgets > Import Excel.',
prefill_from: 'Pre-fill with the lines of',
prefill_none: 'Blank template',
download: 'Download',
year_rate_pct: 'Year rate (%)',
user_department_hint: 'Users can only edit and approve budgets of their own department. Upper Management sees every department, with or without one.',
nav_activity: "Activity",
search_log: "Search…",
nav_mail: "Email",
delete_budget: "Delete budget",
delete_budget_text: "This deletes the {dept} {year} budget and all its lines. Its reports stop being available to everyone. It can't be undone. The activity log keeps who deleted it.",
delete_budget_confirm: "Type {year} to confirm",
budget_deleted: "The {dept} {year} budget was deleted.",
send_email: "Send by email",
email_title: "Send reports by email",
email_to: "To",
email_cc: "Cc",
email_to_hint: "Separate several addresses with commas.",
email_copy_me: "Send me a copy",
email_subject: "Subject",
email_message: "Message",
email_attachments: "Attachments",
email_send: "Send",
email_sending: "Sending…",
email_options: "Reports in {lang} · {site}",
email_default_message: "Hello,\n\nPlease find attached the {title} ({dept}).\n\nBest regards,\n{name}",
email_sent: "Email sent to {to}.",
email_copy_saved: "Copy saved in the \"{folder}\" folder.",
email_copy_failed: "The email was sent, but the copy could not be saved in the Sent folder ({detail}).",
email_not_configured: "Sending by email is not set up yet.",
email_configure: "Set up email",
mail_title: "Email",
mail_tab_account: "Sending account",
mail_tab_log: "Mail log",
mail_enabled: "Sending by email is on",
mail_enabled_hint: "When on, every user can email reports from the Reports page.",
mail_smtp: "Outgoing server (SMTP)",
host: "Server",
port: "Port",
security: "Security",
security_starttls: "STARTTLS (usually port 587)",
security_ssl: "SSL/TLS (usually 465 for SMTP, 993 for IMAP)",
security_none: "No encryption (trusted networks only)",
username: "Username",
password_keep_hint: "Leave empty to keep the saved password.",
password_saved: "saved",
mail_sender: "Sender",
from_address: "Sender address",
from_name: "Sender name",
reply_to: "Reply to (optional)",
signature: "Signature (optional)",
mail_sent_copy: "Copy in the Sent folder (IMAP)",
save_to_sent: "Save a copy of every email sent in the server's Sent folder",
save_to_sent_hint: "Gmail already keeps a copy of messages sent over SMTP. With Gmail, turn this off to avoid duplicate copies.",
imap_host_hint: "Empty: uses the SMTP server.",
imap_same_login: "Use the same username and password as SMTP",
sent_folder: "Sent folder",
sent_folder_hint: "Empty: found automatically (Sent, Sent Items, Enviados…).",
mail_check: "Test connection",
mail_test_send: "Send test email",
mail_test_to: "Address for the test",
mail_check_smtp: "SMTP: {result}",
mail_check_imap: "IMAP: {result}",
mail_check_folder: "Sent folder: \"{folder}\"",
ok: "OK",
mail_saved: "Settings saved.",
mail_save_first: "Save the settings before testing.",
last_changed: "Changed by {name} · {when}",
log_sent: "Sent",
log_failed: "Failed",
copy_saved: "Copy saved",
copy_failed: "Copy failed",
copy_off: "No copy",
from_date: "From",
to_date: "To",
all_users: "All users",
all_statuses: "All statuses",
all_areas: "All areas",
all_actions: "All actions",
export_csv: "Export CSV",
results_count: "{n} entries",
prev: "Previous",
next: "Next",
details: "Details",
activity_title: "Activity log",
activity_hint: "Everything users do: sign-ins, changes to budgets and lines, approvals, imports, reports viewed, downloaded and emailed, and admin changes.",
mail_log_hint: "Every email the app sent: who sent it, to whom, what, and whether the copy reached the Sent folder.",
col_area: "Area",
col_action: "Action",
col_budget: "Budget",
col_to: "To",
col_subject: "Subject",
col_status: "Status",
col_copy: "Sent folder",
col_attachments: "Attachments",
col_from: "From",
deleted: "deleted",
test_email: "test",
area_user: "User",
area_year: "Budget",
area_line: "Line",
area_report: "Report",
area_mail: "Email",
area_mail_settings: "Email settings",
area_category: "Category",
area_site: "Site",
area_department: "Department",
area_template: "Excel template",
act_create: "created",
act_update: "changed",
act_delete: "deleted",
act_login: "signed in",
act_logout: "signed out",
act_login_failed: "failed sign-in",
act_password_changed: "changed password",
act_import: "imported",
act_submit: "submitted",
act_approve: "approved",
act_return: "returned",
act_reopen: "reopened",
act_view: "viewed",
act_download: "downloaded",
act_send: "sent",
act_send_failed: "send failed",
act_check: "tested connection",
act_workflow: "changed workflow",
act_copy_categories: "copied categories",
upload_excel: "Upload Excel",
request_reopen: "Request reopening",
send_request: "Send request",
archived_note: "Archived version, {when} by {name}: it was replaced by an uploaded file. It is locked and can no longer be changed.",
reopen_pending: "{name} asked for this budget to be reopened ({when}):",
reopen_accept: "Accept and reopen",
reopen_decline: "Decline",
loaded_from: "Loaded from the file {file}",
reopened_notice: "The budget was reopened and is a draft again.",
reopen_sent_notice: "Request sent. The department's approvers will decide.",
budget_file: "Budget file (editable Excel)",
budget_file_hint: "Change it and upload it again",
reopen_direct_hint: "The budget goes back to draft. The reason is kept in the history.",
reopen_request_hint: "The department's approvers get the request and decide whether the budget goes back to draft.",
decline_reason: "Reason for declining",
reopen_reason: "Reason",
reason_min: "Required (at least 5 characters).",
reopen_requests: "Reopen requests",
changes: "Changes",
req_pending: "Pending",
req_accepted: "Accepted",
req_declined: "Declined",
import_archived: "The previous version was kept as \"{title}\" and locked.",
file_exists_title: "The file already exists",
file_exists_same: "The file {file} already exists in the app ({dept} {year}).",
file_exists_budget: "There is already a {dept} {year} budget in the app.",
file_exists_replace: "The current data will be replaced by the file. The current version is not deleted: it is kept with the date and time in its name and locked against changes.",
current_version: "Current version",
renamed_to: "Renamed to",
locked: "locked",
file_exists_locked: "It can't be replaced: the budget is {status}. Request reopening first.",
reopen_requested_badge: "Reopening requested",
show_archived: "Show archived versions",
hide_archived: "Hide archived versions",
no_archived: "No archived versions.",
archived_when: "Archived",
archived_badge: "Archived",
act_archive: "archived",
act_reopen_requested: "requested reopening",
act_reopen_declined: "declined reopening",
theme: 'Appearance',
theme_system: 'Automatic (from the computer)',
theme_light: 'Light',
theme_dark: 'Dark',
theme_switch: 'Switch between light, dark and automatic',
avatar: 'Picture',
avatar_upload: 'Choose picture',
avatar_remove: 'Remove',
avatar_hint: 'JPG, PNG, GIF or WebP up to 5 MB. It is cropped to a square and reduced to 256 px.',
security: 'Security',
change_password: 'Change password',
mfa: 'Two-step sign-in (MFA)',
mfa_short: 'MFA',
mfa_on: 'On',
mfa_off: 'Off',
mfa_on_since: 'On since {when}. {n} recovery codes left.',
mfa_off_text: 'Besides the password, asks for a 6-digit code from an app on your phone (Microsoft Authenticator, Google Authenticator, Authy…).',
mfa_setup: 'Turn on MFA',
mfa_disable: 'Turn off MFA',
mfa_new_codes: 'New recovery codes',
mfa_step1: '1. Open your authenticator app and scan this QR code.',
mfa_manual: 'Or type this key in the app:',
mfa_step2: '2. Type the 6-digit code the app shows.',
mfa_code: 'Code',
mfa_confirm: 'Confirm and turn on',
mfa_codes_title: 'Save your recovery codes',
mfa_codes_text: 'Each code works once, if you are ever without your phone. Keep them somewhere safe: they are not shown again.',
mfa_codes_copy: 'Copy',
mfa_codes_done: 'I have saved the codes',
mfa_disable_text: 'To turn it off, type your password and a code from the app (or a recovery code).',
mfa_new_codes_text: 'The old codes stop working. Type a code from the app to continue.',
mfa_login_title: 'Verification code',
mfa_login_text: 'Open your authenticator app and type the 6-digit code. Without your phone, use a recovery code.',
mfa_back: 'Back',
mfa_verify: 'Verify',
mfa_reset: 'Reset MFA',
mfa_reset_text: "Turns off this user's MFA (for example if they lost their phone). They can turn it on again in their profile.",
mfa_reset_done: 'MFA turned off for this user.',
act_mfa_enabled: 'turned on MFA',
act_mfa_disabled: 'turned off MFA',
act_mfa_reset: 'reset MFA',
act_mfa_failed: 'entered a wrong MFA code',
act_mfa_recovery_used: 'used a recovery code',
act_mfa_recovery_renewed: 'created new recovery codes',
act_avatar_changed: 'changed their picture',
act_avatar_removed: 'removed their picture',
errors: {
invalid_mfa_code: 'Wrong or already used code. Try the next code from the app.',
mfa_locked: 'Too many wrong codes. Wait 5 minutes and try again.',
mfa_setup_first: 'Start turning on MFA again.',
mfa_already_on: 'MFA is already on.',
mfa_off: 'MFA is not on.',
not_an_image: 'The file is not a picture.',
image_too_large: 'The picture is larger than 5 MB.',
reason_required: "Give the reason (at least 5 characters).",
reopen_already_requested: "There is already a pending reopen request.",
year_not_draft: "The budget is not a draft. Request reopening first.",
mail_not_configured: "Sending by email is not set up yet. Ask an administrator.",
mail_incomplete: "Fill in the SMTP server and the sender address.",
invalid_address: "Invalid email address.",
no_recipients: "Add at least one recipient.",
too_many_recipients: "Too many recipients (25 at most).",
no_attachments: "Choose at least one report to attach.",
smtp_auth: "The mail server rejected the username or password.",
smtp_connect: "Could not connect to the mail server. Check the server and port.",
smtp_tls: "The secure (TLS) connection to the mail server failed. Check the security option.",
smtp_recipients: "The server rejected one or more recipients.",
smtp_error: "The mail server returned an error.",
invalid_message: "The email could not be built.",
password_unreadable: "The saved password can no longer be read (SECRET_KEY changed). Enter it again.",
invalid_security: "Invalid security option.",
no_months_selected: 'Choose at least one month.',
invalid_date: "Invalid date.",
file_too_large: "The file is too large.",
invalid_department: 'Choose a valid department.',
workflow_in_use: "This department has budgets in review. Approve or return them before changing the workflow.",
step_needs_approvers: 'Choose at least one user for each step with selected users.',
invalid_step: 'Every step needs a name.',
template_header_missing: "The template's header row was not found.",
invalid_transition: 'The budget changed in the meantime. Refresh the page.',
year_exists: 'That year already exists.',
year_not_draft: 'The budget is not a draft.',
missing_fields: 'Fill in the required fields.',
not_an_xlsx: 'The file is not a valid Excel file (.xlsx).',
missing_sheets: 'The file has no "Sheet1" and "Budget IT" sheets.',
year_not_found: 'Could not detect the year. Please enter it.',
email_taken: 'That email is already registered.',
password_too_short: 'The password needs at least 8 characters.',
wrong_password: 'The current password is wrong.',
category_in_use: 'The category has lines and cannot be deleted. Deactivate it instead.',
name_taken: 'That name already exists.',
forbidden: 'You do not have permission for this.',
cannot_demote_self: 'You cannot remove your own administrator access.',
},
}
export const dictionaries = { pt, en }
export function makeT(lang, vars = {}) {
const dict = dictionaries[lang] || pt
const t = (key, params = {}) => {
const text = dict[key] ?? pt[key] ?? key
return String(text).replace(/\{(\w+)\}/g, (_, k) => (params[k] ?? vars[k] ?? `{${k}}`))
}
t.error = (code) => {
const base = String(code || '').split(':')[0]
if (base === 'invalid_expression') return t('invalid_expression')
return dict.errors[base] || t('error', { msg: code })
}
t.lang = lang
return t
}
export const I18nContext = createContext(makeT('pt'))
export const useT = () => useContext(I18nContext)
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote } from '../components/ui.jsx'
const PAGE = 100
// Filters shared by the activity log and the mail log: search, user, from/to dates.
export function useLogFilters(extra = {}) {
const [filters, setFilters] = useState({ q: '', user_id: '', date_from: '', date_to: '', ...extra })
const [offset, setOffset] = useState(0)
const set = (k) => (e) => { setFilters({ ...filters, [k]: e.target.value }); setOffset(0) }
const query = (withPage = true) => {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => v && q.set(k, v))
if (withPage) { q.set('limit', PAGE); q.set('offset', offset) }
return q.toString()
}
return { filters, set, offset, setOffset, query }
}
export function Pager({ total, offset, setOffset }) {
const t = useT()
if (total <= PAGE) return <span className="muted small">{t('results_count', { n: total })}</span>
return (
<div className="pager">
<span className="muted small">{offset + 1}–{Math.min(offset + PAGE, total)} / {total}</span>
<button className="small" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>{t('prev')}</button>
<button className="small" disabled={offset + PAGE >= total} onClick={() => setOffset(offset + PAGE)}>{t('next')}</button>
</div>
)
}
export function UserDateFilters({ f, users }) {
const t = useT()
return <>
<input type="search" placeholder={t('search_log')} value={f.filters.q} onChange={f.set('q')} />
<select value={f.filters.user_id} onChange={f.set('user_id')} aria-label={t('who')}>
<option value="">{t('all_users')}</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<label className="inline">{t('from_date')} <input type="date" value={f.filters.date_from} onChange={f.set('date_from')} /></label>
<label className="inline">{t('to_date')} <input type="date" value={f.filters.date_to} onChange={f.set('date_to')} /></label>
</>
}
const label = (t, prefix, key) => {
const text = t(`${prefix}_${key}`)
return text === `${prefix}_${key}` ? key : text
}
function describe(r) {
const changes = r.changes && typeof r.changes === 'object' && !Array.isArray(r.changes)
? Object.entries(r.changes).map(([k, v]) => Array.isArray(v) && v.length === 2 ? `${k}: ${v[0] ?? '∅'} → ${v[1] ?? '∅'}` : null).filter(Boolean)
: []
return changes
}
export default function Activity() {
const t = useT()
const { lang } = useAuth()
const f = useLogFilters({ entity: '', action: '' })
const [data, setData] = useState(null)
const [users, setUsers] = useState([])
const [open, setOpen] = useState(null)
const [error, setError] = useState(null)
const q = f.query()
useEffect(() => { api.get('/api/users').then(setUsers) }, [])
useEffect(() => { api.get(`/api/activity?${q}`).then(setData).catch(setError) }, [q])
return (
<div>
<div className="page-head">
<h1>{t('activity_title')}</h1>
<a className="button" href={`/api/activity.csv?${f.query(false)}`}>{t('export_csv')}</a>
</div>
<p className="muted small">{t('activity_hint')}</p>
<div className="toolbar wrap">
<UserDateFilters f={f} users={users} />
<select value={f.filters.entity} onChange={f.set('entity')} aria-label={t('col_area')}>
<option value="">{t('all_areas')}</option>
{(data?.entities || []).map((e) => <option key={e} value={e}>{label(t, 'area', e)}</option>)}
</select>
<select value={f.filters.action} onChange={f.set('action')} aria-label={t('col_action')}>
<option value="">{t('all_actions')}</option>
{(data?.actions || []).map((a) => <option key={a} value={a}>{label(t, 'act', a)}</option>)}
</select>
</div>
<ErrorNote error={error} />
{data && (
<div className="card">
<div className="card-head"><Pager total={data.total} offset={f.offset} setOffset={f.setOffset} /></div>
{data.rows.length === 0 ? <p className="muted">{t('nothing_yet')}</p> : (
<table className="plain log">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('col_area')}</th><th>{t('col_action')}</th><th>{t('col_budget')}</th><th>{t('details')}</th></tr></thead>
<tbody>{data.rows.map((r) => {
const changes = describe(r)
return (
<tr key={r.id} className={r.action.endsWith('failed') ? 'bad' : ''}>
<td className="nowrap">{dateTime(r.at, lang)}</td>
<td>{r.user || <span className="muted">—</span>}</td>
<td>{label(t, 'area', r.entity)}</td>
<td>{label(t, 'act', r.action)}</td>
<td className="nowrap">{r.budget || ''}{r.year_id && !r.budget_exists && <span className="muted small"> ({t('deleted')})</span>}</td>
<td className="small">
{r.summary}
{changes.length > 0 && (open === r.id
? <ul className="changes">{changes.map((c, i) => <li key={i}>{c}</li>)}</ul>
: <button className="link small" onClick={() => setOpen(r.id)}>+{changes.length}</button>)}
</td>
</tr>
)
})}</tbody>
</table>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Departments() {
const t = useT()
const [depts, setDepts] = useState([])
const [users, setUsers] = useState([])
const [creating, setCreating] = useState(false)
const [error, setError] = useState(null)
const load = useCallback(() => {
api.get('/api/departments').then(setDepts)
api.get('/api/users').then(setUsers)
}, [])
useEffect(() => { load() }, [load])
return (
<div>
<div className="page-head">
<h1>{t('departments')}</h1>
<button className="primary" onClick={() => setCreating(true)}>{t('new_department')}</button>
</div>
<p className="muted small">{t('workflow_hint')}</p>
<ErrorNote error={error} />
{depts.map((d) => (
<DepartmentCard key={d.id} dept={d} users={users} onChanged={load} onError={setError} />
))}
{creating && <NewDepartment depts={depts} onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load() }} />}
</div>
)
}
function DepartmentCard({ dept, users, onChanged, onError }) {
const t = useT()
const [name, setName] = useState(dept.name)
const [steps, setSteps] = useState(dept.steps)
const [saved, setSaved] = useState(false)
useEffect(() => { setName(dept.name); setSteps(dept.steps) }, [dept])
const members = users.filter((u) => u.department_id === dept.id && u.active)
const dirty = JSON.stringify(steps) !== JSON.stringify(dept.steps)
const rename = async () => {
if (!name.trim() || name === dept.name) return
try { await api.put(`/api/departments/${dept.id}`, { name }); onChanged() } catch (e) { onError(e) }
}
const toggle = async () => {
try { await api.put(`/api/departments/${dept.id}`, { active: !dept.active }); onChanged() } catch (e) { onError(e) }
}
const update = (i, patch) => setSteps(steps.map((s, k) => (k === i ? { ...s, ...patch } : s)))
const move = (i, dir) => {
const next = [...steps]
const [s] = next.splice(i, 1)
next.splice(i + dir, 0, s)
setSteps(next)
}
const save = async () => {
try {
await api.put(`/api/departments/${dept.id}/workflow`, {
steps: steps.map((s) => ({ name: s.name, approver_mode: s.approver_mode, approver_ids: s.approver_ids || [] })),
})
onError(null)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
onChanged()
} catch (e) { onError(e) }
}
return (
<section className={'card dept' + (dept.active ? '' : ' muted')}>
<div className="cat-head">
<h2 className="dept-name"><input className="cell title" value={name} onChange={(e) => setName(e.target.value)} onBlur={rename}
onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} aria-label={t('name')} /></h2>
<span className="muted small">{members.length} {t('nav_users').toLowerCase()}</span>
<label className="check"><input type="checkbox" checked={dept.active} onChange={toggle} /> {t('active')}</label>
</div>
<div className="dept-body">
<h3>{t('workflow')}</h3>
{steps.length === 0 && <p className="muted small">{t('workflow_default')}</p>}
<ol className="steps">
{steps.map((s, i) => (
<li key={i} className="step">
<div className="step-row">
<span className="step-num">{i + 1}</span>
<input value={s.name} placeholder={t('step_name')} onChange={(e) => update(i, { name: e.target.value })} aria-label={t('step_name')} />
<select value={s.approver_mode} onChange={(e) => update(i, { approver_mode: e.target.value })} aria-label={t('approver_mode')}>
<option value="role">{t('mode_role')}</option>
<option value="users">{t('mode_users')}</option>
</select>
<button className="icon" disabled={i === 0} onClick={() => move(i, -1)} title={t('move_up')} aria-label={t('move_up')}>↑</button>
<button className="icon" disabled={i === steps.length - 1} onClick={() => move(i, 1)} title={t('move_down')} aria-label={t('move_down')}>↓</button>
<button className="icon danger" onClick={() => setSteps(steps.filter((_, k) => k !== i))} title={t('delete')} aria-label={t('delete')}>×</button>
</div>
{s.approver_mode === 'users' && (
<div className="user-picks">
{users.filter((u) => u.active && u.role !== 'management').map((u) => (
<label key={u.id} className="check pick">
<input type="checkbox" checked={(s.approver_ids || []).includes(u.id)}
onChange={(e) => update(i, { approver_ids: e.target.checked ? [...(s.approver_ids || []), u.id] : (s.approver_ids || []).filter((x) => x !== u.id) })} />
{u.name} <span className="muted small">{u.department || t('no_department')}</span>
</label>
))}
</div>
)}
</li>
))}
</ol>
<div className="actions">
<button onClick={() => setSteps([...steps, { name: '', approver_mode: 'role', approver_ids: [] }])}>+ {t('add_step')}</button>
<button className="primary" disabled={!dirty} onClick={save}>{t('save_workflow')}</button>
{saved && <span className="muted">{t('saved')}</span>}
</div>
</div>
</section>
)
}
function NewDepartment({ depts, onClose, onSaved }) {
const t = useT()
const [name, setName] = useState('')
const [source, setSource] = useState('defaults')
const [error, setError] = useState(null)
const submit = async () => {
try {
await api.post('/api/departments', { name, categories_from: source })
onSaved()
} catch (e) { setError(e) }
}
return (
<Modal title={t('new_department')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('create')}</button></>}>
<Field label={t('name')}><input value={name} onChange={(e) => setName(e.target.value)} autoFocus /></Field>
<Field label={t('categories_from')}>
<select value={source} onChange={(e) => setSource(e.target.value)}>
<option value="defaults">{t('categories_defaults')}</option>
<option value="none">{t('categories_none')}</option>
{depts.map((d) => <option key={d.id} value={d.id}>{t('categories_copy', { name: d.name })}</option>)}
</select>
</Field>
<ErrorNote error={error} />
</Modal>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ThemeButton } from '../components/ui.jsx'
export default function Login({ onLogin }) {
const t = useT()
const { lang, setLang } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
// with MFA on, a second step asks for the authenticator (or recovery) code
const [mfa, setMfa] = useState(false)
const [code, setCode] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await api.post('/api/auth/login', mfa ? { email, password, code } : { email, password })
if (res.mfa_required) setMfa(true)
else onLogin(res)
} catch (err) {
setError(mfa ? t.error(err.code) : t('invalid_credentials'))
if (mfa) setCode('')
} finally {
setBusy(false)
}
}
const back = () => { setMfa(false); setCode(''); setError(null) }
return (
<div className="login-wrap">
<form className="card login" onSubmit={submit}>
<div className="brand big"><span className="logo" aria-hidden="true"><i /><i /><i /></span>{t('app_name')}</div>
{!mfa ? <>
<h1>{t('login_title')}</h1>
<label className="field"><span className="field-label">{t('email')}</span>
<input type="email" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
</label>
<label className="field"><span className="field-label">{t('password')}</span>
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
</> : <>
<h1>{t('mfa_login_title')}</h1>
<p className="muted small">{t('mfa_login_text')}</p>
<label className="field"><span className="field-label">{t('mfa_code')}</span>
<input className="code" inputMode="text" autoComplete="one-time-code" maxLength={12} value={code}
onChange={(e) => setCode(e.target.value)} required autoFocus />
</label>
</>}
{error && <div className="error" role="alert">{error}</div>}
<button className="primary" disabled={busy}>{mfa ? t('mfa_verify') : t('sign_in')}</button>
{mfa && <button type="button" className="link" onClick={back}>{t('mfa_back')}</button>}
<div className="login-tools">
<div className="lang">
{['pt', 'en'].map((l) => (
<button type="button" key={l} className={l === lang ? 'on' : ''} onClick={() => setLang(l)}>{l.toUpperCase()}</button>
))}
</div>
<ThemeButton />
</div>
</form>
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { Fragment, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field } from '../components/ui.jsx'
import { Pager, UserDateFilters, useLogFilters } from './Activity.jsx'
export default function Mail() {
const t = useT()
const [params, setParams] = useSearchParams()
const tab = params.get('tab') === 'log' ? 'log' : 'account'
return (
<div>
<div className="page-head"><h1>{t('mail_title')}</h1></div>
<div className="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'account'} className={tab === 'account' ? 'on' : ''} onClick={() => setParams({})}>{t('mail_tab_account')}</button>
<button role="tab" aria-selected={tab === 'log'} className={tab === 'log' ? 'on' : ''} onClick={() => setParams({ tab: 'log' })}>{t('mail_tab_log')}</button>
</div>
{tab === 'account' ? <Account /> : <MailLog />}
</div>
)
}
function Account() {
const t = useT()
const { user, lang } = useAuth()
const [saved, setSaved] = useState(null)
const [form, setForm] = useState(null)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [check, setCheck] = useState(null)
const [testTo, setTestTo] = useState(user.email)
const [busy, setBusy] = useState(false)
const take = (s) => { setSaved(s); setForm({ ...s, smtp_password: '', imap_password: '' }); setDirty(false) }
useEffect(() => { api.get('/api/mail/settings').then(take).catch(setError) }, [])
if (!form) return <ErrorNote error={error} />
const set = (k, numeric) => (e) => {
const v = e.target.type === 'checkbox' ? e.target.checked : numeric ? Number(e.target.value) : e.target.value
const next = { ...form, [k]: v }
// moving between the usual ports follows the security choice
if (k === 'smtp_security') next.smtp_port = { starttls: 587, ssl: 465, none: 25 }[v]
if (k === 'imap_security') next.imap_port = { starttls: 143, ssl: 993, none: 143 }[v]
setForm(next)
setDirty(true)
}
const run = async (fn) => {
setBusy(true); setError(null); setNotice(null)
try { await fn() } catch (e) { setError(e) } finally { setBusy(false) }
}
const save = () => run(async () => {
const body = { ...form }
for (const k of ['ready', 'updated_at', 'updated_by', 'smtp_password_set', 'imap_password_set']) delete body[k]
take(await api.put('/api/mail/settings', body))
setNotice(t('mail_saved'))
})
const test = (to) => run(async () => {
setCheck(null)
const r = await api.post('/api/mail/test', to ? { to } : {})
setCheck(to ? { mail: r } : r)
})
const security = (k) => (
<select value={form[k]} onChange={set(k)}>
{['starttls', 'ssl', 'none'].map((s) => <option key={s} value={s}>{t(`security_${s}`)}</option>)}
</select>
)
const result = (text) => (text === 'ok' ? t('ok') : t.error(text))
return (
<div className="mail-form">
<ErrorNote error={error} />
{notice && <div className="notice">{notice}</div>}
<section className="card">
<label className="check big"><input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> {t('mail_enabled')}</label>
<p className="hint">{t('mail_enabled_hint')}</p>
{saved.updated_by && <p className="hint">{t('last_changed', { name: saved.updated_by, when: dateTime(saved.updated_at, lang) })}</p>}
</section>
<section className="card">
<h2>{t('mail_smtp')}</h2>
<div className="grid3">
<Field label={t('host')}><input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" /></Field>
<Field label={t('security')}>{security('smtp_security')}</Field>
<Field label={t('port')}><input type="number" value={form.smtp_port} onChange={set('smtp_port', true)} /></Field>
<Field label={t('username')}><input value={form.smtp_username} onChange={set('smtp_username')} autoComplete="off" /></Field>
<Field label={t('password') + (saved.smtp_password_set ? ` (${t('password_saved')})` : '')} hint={saved.smtp_password_set ? t('password_keep_hint') : null}>
<input type="password" value={form.smtp_password} onChange={set('smtp_password')} autoComplete="new-password" />
</Field>
</div>
</section>
<section className="card">
<h2>{t('mail_sender')}</h2>
<div className="grid3">
<Field label={t('from_address')}><input type="email" value={form.from_address} onChange={set('from_address')} placeholder="orcamentos@empresa.pt" /></Field>
<Field label={t('from_name')}><input value={form.from_name} onChange={set('from_name')} /></Field>
<Field label={t('reply_to')}><input type="email" value={form.reply_to} onChange={set('reply_to')} /></Field>
</div>
<Field label={t('signature')}><textarea rows={3} value={form.signature} onChange={set('signature')} /></Field>
</section>
<section className="card">
<h2>{t('mail_sent_copy')}</h2>
<label className="check"><input type="checkbox" checked={form.save_to_sent} onChange={set('save_to_sent')} /> {t('save_to_sent')}</label>
<p className="hint">{t('save_to_sent_hint')}</p>
{form.save_to_sent && <>
<div className="grid3">
<Field label={t('host')} hint={t('imap_host_hint')}><input value={form.imap_host} onChange={set('imap_host')} placeholder={form.smtp_host || 'imap.example.com'} /></Field>
<Field label={t('security')}>{security('imap_security')}</Field>
<Field label={t('port')}><input type="number" value={form.imap_port} onChange={set('imap_port', true)} /></Field>
</div>
<label className="check"><input type="checkbox" checked={form.imap_same_login} onChange={set('imap_same_login')} /> {t('imap_same_login')}</label>
{!form.imap_same_login && <div className="grid3">
<Field label={t('username')}><input value={form.imap_username} onChange={set('imap_username')} autoComplete="off" /></Field>
<Field label={t('password') + (saved.imap_password_set ? ` (${t('password_saved')})` : '')} hint={saved.imap_password_set ? t('password_keep_hint') : null}>
<input type="password" value={form.imap_password} onChange={set('imap_password')} autoComplete="new-password" />
</Field>
</div>}
<div className="grid3">
<Field label={t('sent_folder')} hint={t('sent_folder_hint')}><input value={form.sent_folder} onChange={set('sent_folder')} /></Field>
</div>
</>}
</section>
<div className="actions sticky-foot">
<button className="primary" disabled={busy || !dirty} onClick={save}>{t('save')}</button>
<span className="spacer" />
<button disabled={busy || dirty || !saved.smtp_host} title={dirty ? t('mail_save_first') : ''} onClick={() => test(null)}>{t('mail_check')}</button>
<input type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} aria-label={t('mail_test_to')} />
<button disabled={busy || dirty || !saved.smtp_host || !testTo} title={dirty ? t('mail_save_first') : ''} onClick={() => test(testTo)}>{t('mail_test_send')}</button>
</div>
{busy && <p className="muted small">{t('email_sending')}</p>}
{check && !check.mail && (
<div className="notice">
<div>{t('mail_check_smtp', { result: result(check.smtp) })}</div>
{check.imap !== 'off' && <div>{t('mail_check_imap', { result: result(check.imap) })}</div>}
{check.sent_folder && <div>{t('mail_check_folder', { folder: check.sent_folder })}</div>}
</div>
)}
{check?.mail && <SendResult log={check.mail} />}
</div>
)
}
export function SendResult({ log }) {
const t = useT()
if (log.status !== 'sent') return <div className="error">{t.error(log.error)} <span className="small">{log.error}</span></div>
return (
<div className="notice">
<div>{t('email_sent', { to: [...log.to, ...log.cc].join(', ') })}</div>
{log.sent_copy === 'saved' && <div className="muted small">{t('email_copy_saved', { folder: log.sent_copy_detail })}</div>}
{log.sent_copy === 'failed' && <div className="error small">{t('email_copy_failed', { detail: log.sent_copy_detail })}</div>}
</div>
)
}
function MailLog() {
const t = useT()
const { lang } = useAuth()
const f = useLogFilters({ status: '' })
const [data, setData] = useState(null)
const [users, setUsers] = useState([])
const [open, setOpen] = useState(null)
const [error, setError] = useState(null)
const q = f.query()
useEffect(() => { api.get('/api/users').then(setUsers) }, [])
useEffect(() => { api.get(`/api/mail/log?${q}`).then(setData).catch(setError) }, [q])
const kb = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB')
return (
<div>
<p className="muted small">{t('mail_log_hint')}</p>
<div className="toolbar wrap">
<UserDateFilters f={f} users={users} />
<select value={f.filters.status} onChange={f.set('status')} aria-label={t('col_status')}>
<option value="">{t('all_statuses')}</option>
<option value="sent">{t('log_sent')}</option>
<option value="failed">{t('log_failed')}</option>
</select>
<a className="button" href={`/api/mail/log.csv?${f.query(false)}`}>{t('export_csv')}</a>
</div>
<ErrorNote error={error} />
{data && (
<div className="card">
<div className="card-head"><Pager total={data.total} offset={f.offset} setOffset={f.setOffset} /></div>
{data.rows.length === 0 ? <p className="muted">{t('nothing_yet')}</p> : (
<table className="plain log">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('col_to')}</th><th>{t('col_subject')}</th><th>{t('col_attachments')}</th><th>{t('col_status')}</th><th>{t('col_copy')}</th></tr></thead>
<tbody>{data.rows.map((m) => (<Fragment key={m.id}>
<tr className={'clickable' + (m.status === 'failed' ? ' bad' : '')} onClick={() => setOpen(open === m.id ? null : m.id)}>
<td className="nowrap">{dateTime(m.at, lang)}</td>
<td>{m.user}</td>
<td className="small">{m.to.join(', ')}{m.cc.length > 0 && <div className="muted">cc: {m.cc.join(', ')}</div>}</td>
<td>{m.subject}{m.kind === 'test' && <span className="badge"> {t('test_email')}</span>}{m.context && m.kind !== 'test' && <div className="muted small">{m.context}</div>}</td>
<td className="small">{m.attachments.map((a) => <div key={a.name} className="nowrap">{a.name} <span className="muted">{kb(a.size)}</span></div>)}</td>
<td><span className={'badge ' + (m.status === 'sent' ? 'st-approved' : 'st-failed')}>{t(`log_${m.status}`)}</span></td>
<td><span className={'badge ' + (m.sent_copy === 'saved' ? 'st-approved' : m.sent_copy === 'failed' ? 'st-failed' : '')}>{t(`copy_${m.sent_copy}`)}</span></td>
</tr>
{open === m.id && (
<tr className="detail"><td colSpan={7}>
<dl className="kv wide">
<dt>{t('col_from')}</dt><dd>{m.sender}{m.user_email && <span className="muted"> · {m.user} &lt;{m.user_email}&gt;</span>}</dd>
{m.error && <><dt>{t('col_status')}</dt><dd className="bad-text">{t.error(m.error)} <span className="muted small">{m.error}</span></dd></>}
{m.sent_copy !== 'off' && <><dt>{t('col_copy')}</dt><dd>{m.sent_copy_detail}</dd></>}
<dt>Message-ID</dt><dd className="small muted">{m.message_id}</dd>
</dl>
{m.body && <pre className="mail-body">{m.body}</pre>}
</td></tr>
)}
</Fragment>))}</tbody>
</table>
)}
</div>
)}
</div>
)
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
import { THEMES, useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { Avatar, ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Profile() {
const t = useT()
const { user, setUser, setLang, theme, setTheme } = useAuth()
const [name, setName] = useState(user.name)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
const submit = async (e) => {
e.preventDefault()
try {
const body = { name }
if (next) Object.assign(body, { current_password: current, new_password: next })
setUser(await api.put('/api/auth/me', body))
setCurrent('')
setNext('')
setError(null)
setDone(true)
} catch (err) { setError(err); setDone(false) }
}
return (
<div className="card narrow">
<h1>{t('profile_title')}</h1>
<AvatarPicker />
<form onSubmit={submit}>
<Field label={t('name')}><input value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label={t('email')}><input value={user.email} disabled /></Field>
<Field label={t('role')}><input value={t(`role_${user.role}`)} disabled /></Field>
<Field label={t('language')}>
<select value={user.language} onChange={(e) => { setLang(e.target.value); setUser({ ...user, language: e.target.value }) }}>
<option value="pt">Português</option><option value="en">English</option>
</select>
</Field>
<Field label={t('theme')}>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
{THEMES.map((th) => <option key={th} value={th}>{t(`theme_${th}`)}</option>)}
</select>
</Field>
<h2 className="section-title">{t('change_password')}</h2>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} /></Field>
<Field label={t('new_password')} hint={t('password_optional')}><input type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} /></Field>
<ErrorNote error={error} />
{done && <div className="notice">{t('saved')}</div>}
<button className="primary">{t('save')}</button>
</form>
<MfaSection />
</div>
)
}
function AvatarPicker() {
const t = useT()
const { user, setUser } = useAuth()
const input = useRef(null)
const [error, setError] = useState(null)
const upload = async (e) => {
const file = e.target.files[0]
e.target.value = ''
if (!file) return
const form = new FormData()
form.append('file', file)
try { setUser(await api.put('/api/auth/me/avatar', form)); setError(null) } catch (err) { setError(err) }
}
const remove = async () => {
try { setUser(await api.del('/api/auth/me/avatar')); setError(null) } catch (err) { setError(err) }
}
return (
<div>
<div className="avatar-row">
<Avatar user={user} large />
<div>
<div className="actions">
<button type="button" onClick={() => input.current.click()}>{t('avatar_upload')}</button>
{user.avatar_url && <button type="button" className="link" onClick={remove}>{t('avatar_remove')}</button>}
</div>
<div className="hint small muted" style={{ marginTop: 6 }}>{t('avatar_hint')}</div>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden onChange={upload} />
</div>
<ErrorNote error={error} />
</div>
)
}
function MfaSection() {
const t = useT()
const { user, lang } = useAuth()
const [dialog, setDialog] = useState(null) // setup | disable | codes
return (
<div>
<h2 className="section-title">{t('mfa')} <span className={'badge' + (user.mfa_enabled ? ' mfa-on' : '')}>{user.mfa_enabled ? t('mfa_on') : t('mfa_off')}</span></h2>
{user.mfa_enabled ? <>
<p className="muted small">{t('mfa_on_since', { when: dateTime(user.mfa_enabled_at, lang), n: user.mfa_recovery_left })}</p>
<div className="actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" onClick={() => setDialog('codes')}>{t('mfa_new_codes')}</button>
<button type="button" className="danger" onClick={() => setDialog('disable')}>{t('mfa_disable')}</button>
</div>
</> : <>
<p className="muted small">{t('mfa_off_text')}</p>
<button type="button" className="primary" onClick={() => setDialog('setup')}>{t('mfa_setup')}</button>
</>}
{dialog === 'setup' && <MfaSetup onClose={() => setDialog(null)} />}
{dialog === 'disable' && <MfaDisable onClose={() => setDialog(null)} />}
{dialog === 'codes' && <MfaNewCodes onClose={() => setDialog(null)} />}
</div>
)
}
function RecoveryCodes({ codes, onDone }) {
const t = useT()
const [copied, setCopied] = useState(false)
const copy = () => navigator.clipboard?.writeText(codes.join('\n')).then(() => setCopied(true)).catch(() => {})
return <>
<p>{t('mfa_codes_text')}</p>
<div className="codes">{codes.map((c) => <span key={c}>{c}</span>)}</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={copy}>{copied ? '✓ ' : ''}{t('mfa_codes_copy')}</button>
<button type="button" className="primary" onClick={onDone}>{t('mfa_codes_done')}</button>
</div>
</>
}
function MfaSetup({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [setup, setSetup] = useState(null)
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { api.post('/api/auth/mfa/setup').then(setSetup).catch(setError) }, [])
const confirm = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/enable', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
if (codes) return <Modal title={t('mfa_codes_title')} onClose={onClose}><RecoveryCodes codes={codes} onDone={onClose} /></Modal>
return (
<Modal title={t('mfa_setup')} onClose={onClose}>
{setup && <form onSubmit={confirm}>
<p>{t('mfa_step1')}</p>
<div className="qr" dangerouslySetInnerHTML={{ __html: setup.qr_svg }} />
<p className="muted small">{t('mfa_manual')}</p>
<code className="secret">{setup.secret.match(/.{1,4}/g).join(' ')}</code>
<p>{t('mfa_step2')}</p>
<Field label={t('mfa_code')}>
<input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_confirm')}</button>
</div>
</form>}
{!setup && <ErrorNote error={error} />}
</Modal>
)
}
function MfaDisable({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try { setUser(await api.post('/api/auth/mfa/disable', { password, code })); onClose() } catch (err) { setError(err) }
}
return (
<Modal title={t('mfa_disable')} onClose={onClose}>
<form onSubmit={submit}>
<p>{t('mfa_disable_text')}</p>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required /></Field>
<Field label={t('mfa_code')}><input className="code" autoComplete="one-time-code" maxLength={12} value={code} onChange={(e) => setCode(e.target.value)} required /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_disable')}</button>
</div>
</form>
</Modal>
)
}
function MfaNewCodes({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/recovery-codes', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
return (
<Modal title={codes ? t('mfa_codes_title') : t('mfa_new_codes')} onClose={onClose}>
{codes ? <RecoveryCodes codes={codes} onDone={onClose} /> : (
<form onSubmit={submit}>
<p>{t('mfa_new_codes_text')}</p>
<Field label={t('mfa_code')}><input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_new_codes')}</button>
</div>
</form>
)}
</Modal>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { StatusBadge } from '../components/ui.jsx'
import EmailDialog from '../components/EmailDialog.jsx'
import { Link } from 'react-router-dom'
export default function Reports() {
const t = useT()
const { user, lang } = useAuth()
const [params, setParams] = useSearchParams()
const [years, setYears] = useState([])
const [sites, setSites] = useState([])
const [reportLang, setReportLang] = useState(lang)
const [siteId, setSiteId] = useState('')
const [includeEmpty, setIncludeEmpty] = useState(false)
const yearId = params.get('year') || years[0]?.id
const viewing = params.get('view')
const [mail, setMail] = useState({ enabled: false })
const [emailing, setEmailing] = useState(null)
useEffect(() => {
api.get('/api/years').then(setYears)
api.get('/api/sites').then(setSites)
api.get('/api/mail/status').then(setMail).catch(() => {})
}, [])
const year = years.find((y) => String(y.id) === String(yearId))
const url = (kind, fmt) => {
const q = new URLSearchParams({ lang: reportLang })
if (siteId) q.set('site_id', siteId)
if (includeEmpty && kind === 'budget') q.set('include_empty', 'true')
return `/api/years/${yearId}/reports/${kind}.${fmt}?${q}`
}
const setYear = (id) => setParams({ year: id })
const view = (kind) => setParams({ year: yearId, view: kind })
const email = mail.enabled ? (kind) => setEmailing(kind) : null
if (!years.length) return <div><h1>{t('reports_title')}</h1><p className="muted">{t('no_years')}</p></div>
return (
<div>
<div className="page-head">
<h1>{t('reports_title')}</h1>
</div>
<div className="toolbar">
<select value={yearId} onChange={(e) => setYear(e.target.value)} aria-label={t('year')}>
{years.map((y) => <option key={y.id} value={y.id}>{y.department} · {y.year} · {y.title}</option>)}
</select>
{year && <StatusBadge status={year.status} />}
<select value={reportLang} onChange={(e) => setReportLang(e.target.value)} aria-label={t('language')}>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
<select value={siteId} onChange={(e) => setSiteId(e.target.value)} aria-label={t('site')}>
<option value="">{t('all_sites')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{!mail.enabled && atLeast(user, 'admin') && (
<div className="notice muted small">{t('email_not_configured')} <Link to="/mail">{t('email_configure')}</Link></div>
)}
<div className="report-grid">
<ReportCard title={t('report_cost')} desc={t('report_cost_desc')} onView={() => view('cost-report')} onEmail={email && (() => email('cost-report'))}
pdf={url('cost-report', 'pdf')} xlsx={url('cost-report', 'xlsx')} />
<ReportCard title={t('report_budget')} desc={t('report_budget_desc')} onView={() => view('budget')} onEmail={email && (() => email('budget'))}
pdf={url('budget', 'pdf')} xlsx={url('budget', 'xlsx')}
extra={<label className="check"><input type="checkbox" checked={includeEmpty} onChange={(e) => setIncludeEmpty(e.target.checked)} /> {t('include_empty')}</label>} />
</div>
{viewing && (
<div className="viewer card">
<div className="viewer-head">
<strong>{viewing === 'budget' ? t('report_budget') : t('report_cost')}</strong>
<div className="actions">
<a className="button" href={url(viewing, 'pdf')}>{t('download_pdf')}</a>
<a className="button" href={url(viewing, 'xlsx')}>{t('download_xlsx')}</a>
{email && <button onClick={() => email(viewing)}>{t('send_email')}</button>}
<a className="button" href={url(viewing, 'html')} target="_blank" rel="noreferrer">↗</a>
<button onClick={() => setParams({ year: yearId })}>{t('close')}</button>
</div>
</div>
<iframe title="report" src={url(viewing, 'html')} />
</div>
)}
{emailing && year && (
<EmailDialog year={year} initial={[emailing, 'pdf']} reportLang={reportLang} siteId={siteId}
siteName={sites.find((s) => String(s.id) === String(siteId))?.name} includeEmpty={includeEmpty}
onClose={() => setEmailing(null)} />
)}
</div>
)
}
function ReportCard({ title, desc, onView, onEmail, pdf, xlsx, extra }) {
const t = useT()
return (
<div className="card report">
<h2>{title}</h2>
<p className="muted">{desc}</p>
{extra}
<div className="actions">
<button className="primary" onClick={onView}>{t('view_online')}</button>
<a className="button" href={pdf}>{t('download_pdf')}</a>
<a className="button" href={xlsx}>{t('download_xlsx')}</a>
{onEmail && <button onClick={onEmail}>{t('send_email')}</button>}
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ErrorNote } from '../components/ui.jsx'
export default function Settings() {
const t = useT()
const { user } = useAuth()
const admin = atLeast(user, 'admin')
const [cats, setCats] = useState([])
const [sites, setSites] = useState([])
const [depts, setDepts] = useState([])
const [deptId, setDeptId] = useState(user.department_id || '')
const [copySource, setCopySource] = useState('defaults')
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
const [newCat, setNewCat] = useState('')
const [newSite, setNewSite] = useState('')
useEffect(() => {
api.get('/api/departments').then((ds) => {
setDepts(ds)
setDeptId((cur) => cur || ds[0]?.id || '')
})
}, [])
const load = useCallback(() => {
if (deptId) api.get(`/api/categories?department_id=${deptId}`).then(setCats)
api.get('/api/sites').then(setSites)
}, [deptId])
useEffect(() => { load() }, [load])
const editableDepts = admin ? depts : depts.filter((d) => d.id === user.department_id)
const run = async (fn) => {
try {
await fn()
setError(null)
} catch (e) { setError(e) }
load()
}
const putCat = (c, patch) => run(() => api.put(`/api/categories/${c.id}`, patch))
const putSite = (s, patch) => run(() => api.put(`/api/sites/${s.id}`, patch))
return (
<div>
<h1>{admin ? t('nav_settings') : t('nav_categories')}</h1>
<ErrorNote error={error} />
{notice && <div className="notice">{notice}</div>}
{admin && <section className="card">
<h2>{t('sites')}</h2>
<table className="plain">
<tbody>
{sites.map((s) => (
<tr key={s.id}>
<td><Inline value={s.name} onSave={(v) => putSite(s, { name: v })} /></td>
<td><label className="check"><input type="checkbox" checked={s.active} onChange={(e) => putSite(s, { active: e.target.checked })} /> {t('active')}</label></td>
</tr>
))}
</tbody>
</table>
<form className="inline-add" onSubmit={(e) => { e.preventDefault(); newSite.trim() && run(() => api.post('/api/sites', { name: newSite })).then(() => setNewSite('')) }}>
<input placeholder={t('new_site')} value={newSite} onChange={(e) => setNewSite(e.target.value)} />
<button>{t('create')}</button>
</form>
</section>}
<section className="card">
<div className="cat-head flush">
<h2>{t('categories')}</h2>
<select value={deptId} onChange={(e) => setDeptId(Number(e.target.value))} aria-label={t('department')} className="auto">
{editableDepts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<select value={copySource} onChange={(e) => setCopySource(e.target.value)} aria-label={t('copy_categories')} className="auto">
<option value="defaults">{t('categories_defaults')}</option>
{depts.filter((d) => d.id !== deptId).map((d) => <option key={d.id} value={d.id}>{t('categories_copy', { name: d.name })}</option>)}
</select>
<button onClick={() => run(() => api.post(`/api/departments/${deptId}/categories/copy`, { source: copySource })).then(() => setNotice(t('copy_categories_done')))}>{t('copy_categories')}</button>
</div>
<div className="table-wrap">
<table className="plain">
<thead><tr><th>{t('order')}</th><th>{t('name_en')}</th><th>{t('name_pt')}</th><th>{t('section')}</th><th>{t('active')}</th><th /></tr></thead>
<tbody>
{cats.map((c) => (
<tr key={c.id} className={c.active ? '' : 'muted'}>
<td><Inline value={String(c.sort_order)} short onSave={(v) => putCat(c, { sort_order: Number(v) || 0 })} /></td>
<td><Inline value={c.name_en} onSave={(v) => putCat(c, { name_en: v })} /></td>
<td><Inline value={c.name_pt} onSave={(v) => putCat(c, { name_pt: v })} /></td>
<td>
<select className="cell" value={c.section} onChange={(e) => putCat(c, { section: e.target.value })}>
<option value="cost">{t('section_cost')}</option><option value="revenue">{t('section_revenue')}</option>
</select>
</td>
<td><input type="checkbox" checked={c.active} onChange={(e) => putCat(c, { active: e.target.checked })} aria-label={t('active')} /></td>
<td className="right"><button className="icon danger" aria-label={t('delete')} title={t('delete')}
onClick={() => window.confirm(t('confirm_delete', { name: c.name_en })) && run(() => api.del(`/api/categories/${c.id}`))}>×</button></td>
</tr>
))}
</tbody>
</table>
</div>
<form className="inline-add" onSubmit={(e) => { e.preventDefault(); newCat.trim() && run(() => api.post('/api/categories', { name_en: newCat, department_id: deptId })).then(() => setNewCat('')) }}>
<input placeholder={t('new_category')} value={newCat} onChange={(e) => setNewCat(e.target.value)} />
<button>{t('create')}</button>
</form>
</section>
</div>
)
}
function Inline({ value, onSave, short }) {
const [text, setText] = useState(value)
useEffect(() => setText(value), [value])
return <input className={'cell' + (short ? ' short' : '')} value={text} onChange={(e) => setText(e.target.value)}
onBlur={() => text !== value && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { ROLES, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { Avatar, ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Users() {
const t = useT()
const [users, setUsers] = useState([])
const [editing, setEditing] = useState(null)
const [depts, setDepts] = useState([])
const load = useCallback(() => api.get('/api/users').then(setUsers), [])
useEffect(() => { load(); api.get('/api/departments').then(setDepts) }, [load])
return (
<div>
<div className="page-head">
<h1>{t('users_title')}</h1>
<button className="primary" onClick={() => setEditing({})}>{t('new_user')}</button>
</div>
<p className="muted small">{t('roles_hint')}</p>
<div className="card">
<table className="plain">
<thead><tr><th>{t('name')}</th><th>{t('email')}</th><th>{t('department')}</th><th>{t('role')}</th><th>{t('mfa_short')}</th><th>{t('language')}</th><th /><th /></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.active ? '' : 'muted'}>
<td><span className="namecell"><Avatar user={u} />{u.name}</span></td><td>{u.email}</td><td>{u.department || <span className="muted">{t('no_department')}</span>}</td><td>{t(`role_${u.role}`)}</td>
<td><span className={'badge' + (u.mfa_enabled ? ' mfa-on' : '')}>{u.mfa_enabled ? t('mfa_on') : t('mfa_off')}</span></td>
<td>{u.language.toUpperCase()}</td>
<td>{u.active ? t('active') : t('inactive')}</td>
<td className="right"><button className="small" onClick={() => setEditing(u)}>{t('edit')}</button></td>
</tr>
))}
</tbody>
</table>
</div>
{editing && <UserDialog user={editing} depts={depts} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
</div>
)
}
function UserDialog({ user, depts, onClose, onSaved }) {
const t = useT()
const { user: me } = useAuth()
const isNew = !user.id
const [form, setForm] = useState({
name: user.name || '', email: user.email || '', role: user.role || 'editor',
language: user.language || 'pt', active: user.active ?? true, password: '',
department_id: user.id ? (user.department_id ?? '') : (depts[0]?.id ?? ''),
})
const [error, setError] = useState(null)
const [mfaReset, setMfaReset] = useState(false)
const resetMfa = async () => {
try { await api.post(`/api/users/${user.id}/mfa/reset`); setMfaReset(true) } catch (e) { setError(e) }
}
const set = (k) => (e) => setForm({ ...form, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })
const submit = async () => {
const body = { ...form, department_id: form.department_id ? Number(form.department_id) : null }
if (!body.password) delete body.password
try {
if (isNew) await api.post('/api/users', body)
else await api.put(`/api/users/${user.id}`, body)
onSaved()
} catch (e) { setError(e) }
}
return (
<Modal title={isNew ? t('new_user') : user.name} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('save')}</button></>}>
<Field label={t('name')}><input value={form.name} onChange={set('name')} /></Field>
<Field label={t('email')}><input type="email" value={form.email} onChange={set('email')} /></Field>
<Field label={t('department')} hint={t('user_department_hint')}>
<select value={form.department_id} onChange={set('department_id')}>
<option value="">{t('no_department')}</option>
{depts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</Field>
<Field label={t('role')}>
<select value={form.role} onChange={set('role')} disabled={user.id === me.id}>
{ROLES.map((r) => <option key={r} value={r}>{t(`role_${r}`)}</option>)}
</select>
</Field>
<Field label={t('language')}>
<select value={form.language} onChange={set('language')}><option value="pt">Português</option><option value="en">English</option></select>
</Field>
<Field label={isNew ? t('password') : t('new_password')} hint={isNew ? null : t('password_optional')}>
<input type="password" autoComplete="new-password" value={form.password} onChange={set('password')} />
</Field>
{!isNew && user.mfa_enabled && (
<div className="field">
<span className="field-label">{t('mfa')}</span>
{mfaReset ? <div className="notice">{t('mfa_reset_done')}</div> : <>
<span className="hint">{t('mfa_reset_text')}</span>
<div><button type="button" className="small" onClick={resetMfa}>{t('mfa_reset')}</button></div>
</>}
</div>
)}
{!isNew && user.id !== me.id && <label className="check"><input type="checkbox" checked={form.active} onChange={set('active')} /> {t('active')}</label>}
<ErrorNote error={error} />
</Modal>
)
}
@@ -0,0 +1,620 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime, eur, exprForDisplay, num, pct, signedEur } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal, StatusBadge } from '../components/ui.jsx'
import EmailDialog from '../components/EmailDialog.jsx'
import { ImportYear } from './Years.jsx'
const ADJ_KINDS = ['year_rate', 'percent', 'factor', 'none']
const MONTHS = {
pt: ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'],
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
}
const QUARTERS = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
// [1, 2, 3, 4, 7] -> "Jan–Abr, Jul"
export function monthsLabel(list, lang) {
const names = MONTHS[lang] || MONTHS.pt
const runs = []
for (const m of [...list].sort((a, b) => a - b)) {
if (runs.length && m === runs[runs.length - 1][1] + 1) runs[runs.length - 1][1] = m
else runs.push([m, m])
}
return runs.map(([a, b]) => (a === b ? names[a - 1] : `${names[a - 1]}–${names[b - 1]}`)).join(', ')
}
// Same rules as the server: cents, the last month taking the rounding remainder.
function spread(total, chosen) {
const months = chosen.length ? [...chosen].sort((a, b) => a - b) : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const cents = Math.round(total * 100)
const share = Math.round(cents / months.length)
const out = Array(12).fill(0)
months.forEach((m) => { out[m - 1] = share / 100 })
out[months[months.length - 1] - 1] = (cents - share * (months.length - 1)) / 100
return out
}
// Evaluates the simple arithmetic the amount fields accept (12*839,67), for the live preview only.
function previewNumber(text) {
const expr = String(text ?? '').trim().replace(/^=/, '').replace(/\s/g, '').replace(/,/g, '.')
if (!expr || !/^[0-9+\-*/().]+$/.test(expr)) return null
try {
const v = Function(`"use strict";return (${expr})`)()
return Number.isFinite(v) ? v : null
} catch { return null }
}
export default function YearEditor() {
const { id } = useParams()
const location = useLocation()
const t = useT()
const { user, lang } = useAuth()
const [data, setData] = useState(null)
const [sites, setSites] = useState([])
const [categories, setCategories] = useState([])
const [siteFilter, setSiteFilter] = useState('')
const [search, setSearch] = useState('')
const [showEmpty, setShowEmpty] = useState(false)
const [editing, setEditing] = useState(null)
const [showHistory, setShowHistory] = useState(false)
const [deleting, setDeleting] = useState(false)
const [dialog, setDialog] = useState(null) // reopen | decline | email | upload
const [mail, setMail] = useState({ enabled: false })
useEffect(() => { api.get('/api/mail/status').then(setMail).catch(() => {}) }, [])
const navigate = useNavigate()
const [error, setError] = useState(null)
const [notice, setNotice] = useState(location.state?.notice || null)
useEffect(() => { if (location.state?.notice) setNotice(location.state.notice) }, [location.state])
const load = useCallback(() => {
const q = siteFilter ? `?site_id=${siteFilter}` : ''
return api.get(`/api/years/${id}${q}`).then(setData).catch(setError)
}, [id, siteFilter])
useEffect(() => { load() }, [load])
useEffect(() => { api.get('/api/sites').then(setSites) }, [])
const deptId = data?.department_id
useEffect(() => {
if (deptId) api.get(`/api/categories?department_id=${deptId}`).then(setCategories)
}, [deptId])
const actions = data?.actions || {}
const editable = !!actions.edit
const tv = useMemo(() => (data ? { year: data.year, prior: data.year - 1 } : {}), [data])
const saveLine = useCallback(async (lineId, patch) => {
try {
await api.put(`/api/lines/${lineId}`, patch)
setError(null)
await load()
return true
} catch (e) {
setError(e)
return false
}
}, [load])
const removeLine = async (line) => {
if (!window.confirm(t('confirm_delete', { name: line.name }))) return
await api.del(`/api/lines/${line.id}`).catch(setError)
load()
}
const transition = async (action) => {
try {
await api.post(`/api/years/${id}/status`, { action })
load()
} catch (e) { setError(e) }
}
const decide = async (accept) => {
try {
await api.post(`/api/reopen-requests/${data.reopen_request.id}/decide`, { accept })
if (accept) setNotice(t('reopened_notice'))
load()
} catch (e) { setError(e) }
}
const saveRate = async (value) => {
const rate = Number(String(value).replace(',', '.'))
if (Number.isNaN(rate)) return
await api.put(`/api/years/${id}`, { default_rate: rate / 100 }).catch(setError)
load()
}
if (!data) return <ErrorNote error={error} />
const q = search.trim().toLowerCase()
const visible = data.categories
.map((c) => ({ ...c, lines: q ? c.lines.filter((l) => (l.name + ' ' + l.description + ' ' + l.remarks).toLowerCase().includes(q)) : c.lines }))
.filter((c) => c.lines.length || (showEmpty && !q))
const catName = (c) => (lang === 'pt' && c.name_pt) || c.name_en
return (
<div>
<div className="page-head">
<div>
<h1>{data.title || data.year} <span className="dept-tag">{data.department}</span> <StatusBadge status={data.status} />{data.archived && <span className="badge"> 🔒 {t('archived_badge')}</span>}</h1>
<div className="muted small">
{data.submitted_by && t('submitted_by', { name: data.submitted_by }) + ' · ' + dateTime(data.submitted_at, lang)}
{data.approved_by && ' · ' + t('approved_by', { name: data.approved_by }) + ' · ' + dateTime(data.approved_at, lang)}
</div>
</div>
<div className="actions">
<button onClick={() => setShowHistory(true)}>{t('history')}</button>
<DownloadMenu id={data.id} lang={lang} />
{mail.enabled && <button onClick={() => setDialog('email')}>{t('send_email')}</button>}
{actions.upload && <button onClick={() => setDialog('upload')}>{t('upload_excel')}</button>}
{actions.submit && <button className="primary" onClick={() => transition('submit')}>{t('submit_review')}</button>}
{actions.return && <button onClick={() => transition('return')}>{t('return_draft')}</button>}
{actions.approve && <button className="primary" onClick={() => transition('approve')}>{t('approve')}</button>}
{actions.reopen && <button onClick={() => setDialog('reopen')}>{t('reopen')}</button>}
{!actions.reopen && actions.request_reopen && <button onClick={() => setDialog('reopen')}>{t('request_reopen')}</button>}
{actions.delete && <button className="danger" onClick={() => setDeleting(true)}>{t('delete_budget')}</button>}
</div>
</div>
{notice && <div className="notice" onClick={() => setNotice(null)}>{notice}</div>}
{data.archived && <div className="notice locked-note">🔒 {t('archived_note', { when: dateTime(data.archived_at, lang), name: data.archived_by || '—' })}</div>}
{data.reopen_request && (
<div className="notice reopen-note">
<div><strong>{t('reopen_pending', { name: data.reopen_request.requested_by, when: dateTime(data.reopen_request.requested_at, lang) })}</strong></div>
<div className="quote">“{data.reopen_request.reason}”</div>
{actions.decide_reopen && <div className="actions">
<button className="primary" onClick={() => decide(true)}>{t('reopen_accept')}</button>
<button onClick={() => setDialog('decline')}>{t('reopen_decline')}</button>
</div>}
</div>
)}
{data.source_file && !data.archived && <div className="muted small source-file">{t('loaded_from', { file: data.source_file })}</div>}
<Workflow data={data} />
{data.status !== 'draft' && <div className="notice muted">{t('locked_hint', { status: t(`status_${data.status}`).toLowerCase() })}</div>}
{data.status === 'draft' && !editable && user.department_id !== data.department_id &&
<div className="notice muted">{t('view_only_dept', { dept: data.department })}</div>}
<ErrorNote error={error} />
<div className="totals">
<div className="card stat"><div className="k">{t('prior_actual', tv)}</div><div className="v">{eur(data.costs.prior, lang)}</div></div>
<div className="card stat"><div className="k">{t('adjusted', tv)}</div><div className="v">{eur(data.costs.adjusted, lang)}</div></div>
<div className="card stat"><div className="k">{t('change')}</div>
<div className={'v ' + (data.costs.change > 0 ? 'up' : 'down')}>{signedEur(data.costs.change, lang)} <small>{pct(data.costs.change_pct, lang)}</small></div></div>
<div className="card stat"><div className="k">{t('default_rate')}</div>
<div className="v">{editable
? <RateInput value={data.default_rate * 100} lang={lang} onSave={saveRate} />
: num(data.default_rate * 100, lang) + '%'}</div></div>
</div>
<div className="toolbar">
<input type="search" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<select value={siteFilter} onChange={(e) => setSiteFilter(e.target.value)} aria-label={t('site')}>
<option value="">{t('all_sites')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
{editable && <label className="check"><input type="checkbox" checked={showEmpty} onChange={(e) => setShowEmpty(e.target.checked)} /> {t('show_empty')}</label>}
</div>
{visible.map(({ category, lines, totals }) => (
<section key={category.id} className="card cat">
<div className="cat-head">
<h2>{catName(category)} <span className="muted small">{lines.length === 1 ? t('lines_one') : t('lines_count', { n: lines.length })}</span></h2>
<div className="cat-totals">
<span>{eur(totals.prior, lang, 0)}</span><span className="arrow">→</span>
<strong>{eur(totals.adjusted, lang, 0)}</strong>
<span className={totals.change_pct == null ? 'muted' : totals.change > 0 ? 'up' : 'down'}>{pct(totals.change_pct, lang)}</span>
</div>
{editable && <button className="small" onClick={() => setEditing({ category_id: category.id })}>+ {t('add_line')}</button>}
</div>
{lines.length > 0 && (
<div className="table-wrap">
<table className="lines">
<colgroup>
<col /><col className="c-site" /><col className="c-amt" /><col className="c-amt" />
<col className="c-adj" /><col className="c-amt" /><col className="c-pct" /><col className="c-act" />
</colgroup>
<thead><tr>
<th>{t('line')}</th><th>{t('site')}</th>
<th className="num">{t('prior_actual', tv)}</th><th className="num">{t('base', tv)}</th>
<th>{t('adjustment')}</th><th className="num">{t('adjusted', tv)}</th><th className="num">{t('change_pct')}</th><th />
</tr></thead>
<tbody>
{lines.map((l) => (
<LineRow key={l.id} line={l} sites={sites} editable={editable} lang={lang} rate={data.default_rate}
onSave={saveLine} onOpen={() => setEditing(l)} onDelete={() => removeLine(l)} />
))}
</tbody>
</table>
</div>
)}
</section>
))}
{editing && <LineDialog line={editing} yearId={data.id} tv={tv} categories={categories} sites={sites} editable={editable}
rate={data.default_rate} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
{showHistory && <History yearId={data.id} requests={data.reopen_requests} onClose={() => setShowHistory(false)} />}
{(dialog === 'reopen' || dialog === 'decline') && (
<ReasonDialog kind={dialog} direct={actions.reopen} onClose={() => setDialog(null)}
onSubmit={async (text) => {
if (dialog === 'decline') await api.post(`/api/reopen-requests/${data.reopen_request.id}/decide`, { accept: false, note: text })
else {
const r = await api.post(`/api/years/${id}/reopen-requests`, { reason: text })
setNotice(r.reopened ? t('reopened_notice') : t('reopen_sent_notice'))
}
setDialog(null); load()
}} />
)}
{dialog === 'email' && <EmailDialog year={data} initial={['workbook', 'xlsx']} reportLang={lang} onClose={() => setDialog(null)} />}
{dialog === 'upload' && <ImportYear depts={[{ id: data.department_id, name: data.department }]} initialDept={String(data.department_id)} onClose={() => setDialog(null)} />}
{deleting && <DeleteBudget data={data} onClose={() => setDeleting(false)}
onDeleted={() => navigate('/', { state: { notice: t('budget_deleted', { dept: data.department, year: data.year }) } })} />}
</div>
)
}
function Workflow({ data }) {
const t = useT()
const steps = data.workflow.length ? data.workflow : [{ name: t('approve'), id: 'default' }]
const stepState = (i) => (data.status === 'approved' ? 'done'
: data.status === 'in_review' ? (i < (data.current_step || 0) ? 'done' : i === (data.current_step || 0) ? 'now' : 'todo') : 'todo')
return (
<ol className="flow" aria-label={t('workflow')}>
<li className={data.status === 'draft' ? 'now' : 'done'}>{t('status_draft')}</li>
{steps.map((s, i) => <li key={s.id ?? i} className={stepState(i)}>{s.name}</li>)}
<li className={data.status === 'approved' ? 'now done' : 'todo'}>{t('status_approved')}</li>
</ol>
)
}
function RateInput({ value, lang, onSave }) {
const [text, setText] = useState(num(value, lang, 2))
useEffect(() => setText(num(value, lang, 2)), [value, lang])
return (
<span className="rate"><input value={text} inputMode="decimal" onChange={(e) => setText(e.target.value)}
onBlur={() => text !== num(value, lang, 2) && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />%</span>
)
}
function ExprCell({ value, display, lang, editable, onSave, label }) {
const t = useT()
const shown = exprForDisplay(value, lang)
const [text, setText] = useState(shown)
const [bad, setBad] = useState(false)
const [focused, setFocused] = useState(false)
useEffect(() => setText(shown), [shown])
if (!editable) return <span>{display}</span>
const commit = async () => {
setFocused(false)
if (text.trim() === shown) return
const ok = await onSave(text)
setBad(!ok)
}
const isFormula = /[+\-*/()]/.test(String(value ?? '').replace(/^-/, ''))
return (
<input className={'cell num' + (bad ? ' bad' : '')} aria-label={label} title={isFormula ? `= ${shown}` : t('expr_hint')}
value={focused ? text : display} onFocus={() => setFocused(true)} onChange={(e) => setText(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
)
}
function LineRow({ line, sites, editable, lang, onSave, onOpen, onDelete }) {
const t = useT()
const manual = line.monthly_kind === 'manual'
const save = (patch) => onSave(line.id, patch)
const siteName = sites.find((s) => s.id === line.site_id)?.name || t('group')
const adjText = manual ? t('monthly_manual')
: line.adj_kind === 'year_rate' ? `${t('adj_year_rate')} (×${num(line.factor, lang, 3)})`
: line.adj_kind === 'percent' ? `${num(line.adj_value, lang, 2)}%`
: line.adj_kind === 'factor' ? `×${num(line.adj_value, lang, 3)}` : t('adj_none')
return (
<tr>
<td className="name">
{editable
? <TextCell value={line.name} onSave={(v) => save({ name: v })} label={t('name')} />
: line.name}
{(line.adj_reason || line.remarks) && <div className="muted small clip" title={[line.adj_reason, line.remarks].filter(Boolean).join(' · ')}>{line.adj_reason || line.remarks}</div>}
</td>
<td>
{editable ? (
<select className="cell" value={line.site_id ?? ''} aria-label={t('site')}
onChange={(e) => save({ site_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">{t('group')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : siteName}
</td>
<td className="num"><ExprCell value={line.prior_expr} display={num(line.prior, lang)} lang={lang} editable={editable} label={t('prior_actual')}
onSave={(v) => save({ prior_expr: v })} /></td>
<td className="num">{manual ? <span className="muted">{num(line.base, lang)}</span>
: <ExprCell value={line.base_expr} display={num(line.base, lang)} lang={lang} editable={editable} label={t('base')}
onSave={(v) => save({ base_expr: v })} />}</td>
<td>
{editable && !manual ? (
<div className="adj">
<select className="cell" value={line.adj_kind} aria-label={t('adjustment')} onChange={(e) => save({ adj_kind: e.target.value, adj_value: e.target.value === 'factor' ? line.factor : e.target.value === 'percent' ? +((line.factor - 1) * 100).toFixed(4) : 0 })}>
{ADJ_KINDS.map((k) => <option key={k} value={k}>{t(`adj_${k}`)}</option>)}
</select>
{(line.adj_kind === 'percent' || line.adj_kind === 'factor') && (
<NumberCell value={line.adj_value} lang={lang} onSave={(v) => save({ adj_value: v })} />
)}
</div>
) : <span className={manual ? 'muted' : ''}>{adjText}</span>}
{line.monthly_kind === 'selected' && line.monthly_months?.length > 0 &&
<div className="muted small" title={t('monthly_selected')}>{monthsLabel(line.monthly_months, lang)}</div>}
</td>
<td className="num strong">{num(line.adjusted, lang)}</td>
<td className={'num ' + (line.change_pct == null ? 'muted' : line.change > 0 ? 'up' : line.change < 0 ? 'down' : '')}>{pct(line.change_pct, lang)}</td>
<td className="row-actions">
<button className="icon" title={t('details')} aria-label={t('details')} onClick={onOpen}>⋯</button>
{editable && <button className="icon danger" title={t('delete')} aria-label={t('delete')} onClick={onDelete}>×</button>}
</td>
</tr>
)
}
function TextCell({ value, onSave, label }) {
const [text, setText] = useState(value)
useEffect(() => setText(value), [value])
return <input className="cell" aria-label={label} title={value} value={text} onChange={(e) => setText(e.target.value)}
onBlur={() => text.trim() && text !== value && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
function NumberCell({ value, lang, onSave }) {
const [text, setText] = useState(num(value, lang, 3))
useEffect(() => setText(num(value, lang, 3)), [value, lang])
const commit = () => {
const n = Number(text.replace(/\s/g, '').replace(',', '.'))
if (!Number.isNaN(n) && text !== num(value, lang, 3)) onSave(n)
}
return <input className="cell num short" value={text} inputMode="decimal" onChange={(e) => setText(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
function LineDialog({ line, yearId, tv, categories, sites, editable, rate, onClose, onSaved }) {
const t = useT()
const { lang } = useAuth()
const isNew = !line.id
const [form, setForm] = useState({
name: line.name || '', description: line.description || '', remarks: line.remarks || '',
category_id: line.category_id, site_id: line.site_id ?? '',
prior_expr: exprForDisplay(line.prior_expr ?? '0', lang), base_expr: exprForDisplay(line.base_expr ?? '0', lang),
adj_kind: line.adj_kind || 'year_rate', adj_value: line.adj_value ?? 0, adj_reason: line.adj_reason || '',
monthly_kind: line.monthly_kind || 'even',
monthly_months: line.monthly_months || [],
monthly_values: (line.monthly_values || line.months || Array(12).fill(0)).map((v) => exprForDisplay(Math.round(v * 100) / 100, lang)),
})
const [error, setError] = useState(null)
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const months = MONTHS[lang] || MONTHS.pt
const selected = form.monthly_kind === 'selected'
const toggleMonth = (m) => {
const cur = new Set(form.monthly_months)
cur.has(m) ? cur.delete(m) : cur.add(m)
setForm({ ...form, monthly_months: [...cur].sort((a, b) => a - b) })
}
const setMonths = (list) => setForm({ ...form, monthly_months: list })
const toNum = (v) => Number(String(v).replace(/\s/g, '').replace(',', '.')) || 0
const submit = async () => {
const body = {
...form, category_id: Number(form.category_id), site_id: form.site_id ? Number(form.site_id) : null,
adj_value: toNum(form.adj_value),
monthly_values: form.monthly_kind === 'manual' ? form.monthly_values.map(toNum) : null,
monthly_months: selected ? form.monthly_months : null,
}
try {
if (isNew) await api.post(`/api/years/${yearId}/lines`, body)
else await api.put(`/api/lines/${line.id}`, body)
onSaved()
} catch (e) { setError(e) }
}
const ro = !editable
return (
<Modal wide title={isNew ? t('add_line') : form.name} onClose={onClose}
footer={<>
<button onClick={onClose}>{ro ? t('close') : t('cancel')}</button>
{!ro && <button className="primary" disabled={selected && form.monthly_months.length === 0} onClick={submit}>{t('save')}</button>}
</>}>
<fieldset disabled={ro} className="grid2">
<Field label={t('name')}><input value={form.name} onChange={set('name')} /></Field>
<Field label={t('category')}>
<select value={form.category_id} onChange={set('category_id')}>
{categories.filter((c) => c.active || c.id === form.category_id).map((c) => <option key={c.id} value={c.id}>{(lang === 'pt' && c.name_pt) || c.name_en}</option>)}
</select>
</Field>
<Field label={t('description')}><input value={form.description} onChange={set('description')} /></Field>
<Field label={t('site')}>
<select value={form.site_id} onChange={set('site_id')}>
<option value="">{t('group')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</Field>
<Field label={t('months')}>
<select value={form.monthly_kind} onChange={(e) => {
const kind = e.target.value
if (form.monthly_kind === 'manual' && kind !== 'manual') {
// leaving typed months keeps the year total as the base, with no further adjustment
const total = Math.round(form.monthly_values.reduce((a, v) => a + toNum(v), 0) * 100) / 100
setForm({ ...form, monthly_kind: kind, base_expr: exprForDisplay(total, lang), adj_kind: 'none' })
} else setForm({ ...form, monthly_kind: kind })
}}>
<option value="even">{t('monthly_even')}</option>
<option value="selected">{t('monthly_selected')}</option>
<option value="manual">{t('monthly_manual')}</option>
</select>
</Field>
<Field label={t('prior_actual', tv)} hint={t('expr_hint')}><input value={form.prior_expr} onChange={set('prior_expr')} /></Field>
{form.monthly_kind !== 'manual' && <>
<Field label={t('base', tv)} hint={t('expr_hint')}><input value={form.base_expr} onChange={set('base_expr')} /></Field>
<Field label={t('adjustment')} hint={form.adj_kind === 'year_rate' ? `×${num(1 + rate, lang, 3)}` : null}>
<div className="adj">
<select value={form.adj_kind} onChange={set('adj_kind')}>
{ADJ_KINDS.map((k) => <option key={k} value={k}>{t(`adj_${k}`)}</option>)}
</select>
{(form.adj_kind === 'percent' || form.adj_kind === 'factor') &&
<input className="short" inputMode="decimal" value={form.adj_value} onChange={set('adj_value')} />}
</div>
</Field>
<Field label={t('adj_reason')}><input value={form.adj_reason} onChange={set('adj_reason')} /></Field>
</>}
</fieldset>
{form.monthly_kind === 'manual' && (
<fieldset disabled={ro}>
<p className="hint">{t('monthly_hint_manual')}</p>
<div className="months">
{months.map((m, i) => (
<Field key={m} label={m}>
<input inputMode="decimal" value={form.monthly_values[i] ?? 0}
onChange={(e) => { const v = [...form.monthly_values]; v[i] = e.target.value; setForm({ ...form, monthly_values: v }) }} />
</Field>
))}
</div>
<div className="strong right">{t('adjusted', tv)}: {eur(form.monthly_values.reduce((a, v) => a + toNum(v), 0), lang)}</div>
</fieldset>
)}
{selected && (
<fieldset disabled={ro} className="month-pick">
<span className="field-label">{t('monthly_pick')}</span>
<div className="chips" role="group" aria-label={t('monthly_pick')}>
{months.map((m, i) => (
<button type="button" key={m} aria-pressed={form.monthly_months.includes(i + 1)}
className={'chip' + (form.monthly_months.includes(i + 1) ? ' on' : '')} onClick={() => toggleMonth(i + 1)}>{m}</button>
))}
</div>
<div className="chips quick">
{QUARTERS.map((q, i) => <button type="button" key={i} className="link small" onClick={() => setMonths(q)}>{t('quarter', { n: i + 1 })}</button>)}
<button type="button" className="link small" onClick={() => setMonths([1, 2, 3, 4, 5, 6])}>{t('half', { n: 1 })}</button>
<button type="button" className="link small" onClick={() => setMonths([7, 8, 9, 10, 11, 12])}>{t('half', { n: 2 })}</button>
<button type="button" className="link small" onClick={() => setMonths([])}>{t('clear')}</button>
</div>
<p className="hint">{form.monthly_months.length === 0 ? t('monthly_pick_none')
: t('monthly_pick_hint', { n: form.monthly_months.length, months: monthsLabel(form.monthly_months, lang) })}</p>
</fieldset>
)}
{form.monthly_kind !== 'manual' && (() => {
const base = previewNumber(form.base_expr)
if (base == null) return null
const factor = form.adj_kind === 'year_rate' ? 1 + rate : form.adj_kind === 'percent' ? 1 + toNum(form.adj_value) / 100
: form.adj_kind === 'factor' ? toNum(form.adj_value) : 1
const adjusted = Math.round(base * factor * 100) / 100
const values = spread(adjusted, selected ? form.monthly_months : [])
return (
<div className="month-strip">
{months.map((m, i) => <div key={m} className={values[i] ? '' : 'off'}><span className="muted small">{m}</span><span>{num(values[i], lang, 0)}</span></div>)}
</div>
)
})()}
<fieldset disabled={ro}>
<Field label={t('remarks')}><textarea rows={3} value={form.remarks} onChange={set('remarks')} /></Field>
</fieldset>
<ErrorNote error={error} />
</Modal>
)
}
function DownloadMenu({ id, lang }) {
const t = useT()
const q = `?lang=${lang}`
return (
<details className="menu">
<summary className="button">{t('download')} ▾</summary>
<div className="menu-list" onClick={(e) => { e.currentTarget.closest('details').open = false }}>
<a href={`/api/years/${id}/workbook.xlsx${q}`}>{t('budget_file')}<span className="muted small">{t('budget_file_hint')}</span></a>
<a href={`/api/years/${id}/reports/cost-report.pdf${q}`}>{t('report_cost')} · PDF</a>
<a href={`/api/years/${id}/reports/cost-report.xlsx${q}`}>{t('report_cost')} · Excel</a>
<a href={`/api/years/${id}/reports/budget.pdf${q}`}>{t('report_budget')} · PDF</a>
<a href={`/api/years/${id}/reports/budget.xlsx${q}`}>{t('report_budget')} · Excel</a>
</div>
</details>
)
}
function ReasonDialog({ kind, direct, onClose, onSubmit }) {
const t = useT()
const [text, setText] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const title = kind === 'decline' ? t('reopen_decline') : direct ? t('reopen') : t('request_reopen')
const ok = text.trim().length >= 5
const send = async () => {
setBusy(true)
try { await onSubmit(text.trim()) } catch (e) { setError(e); setBusy(false) }
}
return (
<Modal title={title} onClose={onClose} footer={<>
<button onClick={onClose}>{t('cancel')}</button>
<button className="primary" disabled={!ok || busy} onClick={send}>{kind === 'decline' ? t('reopen_decline') : direct ? t('reopen') : t('send_request')}</button>
</>}>
{kind !== 'decline' && <p className="muted small">{direct ? t('reopen_direct_hint') : t('reopen_request_hint')}</p>}
<Field label={kind === 'decline' ? t('decline_reason') + ' *' : t('reopen_reason') + ' *'} hint={!ok ? t('reason_min') : null}>
<textarea rows={4} value={text} onChange={(e) => setText(e.target.value)} autoFocus required />
</Field>
<ErrorNote error={error} />
</Modal>
)
}
function History({ yearId, requests, onClose }) {
const t = useT()
const { lang } = useAuth()
const [rows, setRows] = useState(null)
useEffect(() => { api.get(`/api/years/${yearId}/audit`).then(setRows) }, [yearId])
const describe = (r) => {
const changes = r.changes && r.action === 'update'
? Object.entries(r.changes).map(([k, [a, b]]) => `${k}: ${a ?? '∅'} → ${b ?? '∅'}`).join('; ')
: ''
return [r.summary, changes].filter(Boolean).join(' — ')
}
return (
<Modal wide title={t('history')} onClose={onClose}>
{requests?.length > 0 && <>
<h3 className="sub">{t('reopen_requests')}</h3>
<table className="plain">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('reopen_reason')}</th><th>{t('col_status')}</th></tr></thead>
<tbody>{requests.map((r) => (
<tr key={r.id}><td className="nowrap">{dateTime(r.requested_at, lang)}</td><td>{r.requested_by}</td><td className="small">{r.reason}</td>
<td className="small"><span className={'badge ' + (r.status === 'accepted' ? 'st-approved' : r.status === 'declined' ? 'st-failed' : 'st-in_review')}>{t(`req_${r.status}`)}</span>
{r.decided_by && <div className="muted">{r.decided_by} · {dateTime(r.decided_at, lang)}</div>}
{r.decision_note && <div>{r.decision_note}</div>}</td></tr>
))}</tbody>
</table>
<h3 className="sub">{t('changes')}</h3>
</>}
{rows && rows.length === 0 && <p className="muted">{t('nothing_yet')}</p>}
{rows && rows.length > 0 && (
<table className="plain">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('what')}</th><th /></tr></thead>
<tbody>{rows.map((r, i) => (
<tr key={i}><td className="nowrap">{dateTime(r.at, lang)}</td><td>{r.user}</td><td>{r.entity} · {r.action}</td><td className="small">{describe(r)}</td></tr>
))}</tbody>
</table>
)}
</Modal>
)
}
function DeleteBudget({ data, onClose, onDeleted }) {
const t = useT()
const [typed, setTyped] = useState('')
const [error, setError] = useState(null)
const remove = async () => {
try {
await api.del(`/api/years/${data.id}`)
onDeleted()
} catch (e) { setError(e) }
}
return (
<Modal title={t('delete_budget')} onClose={onClose} footer={<>
<button onClick={onClose}>{t('cancel')}</button>
<button className="danger solid" disabled={typed.trim() !== String(data.year)} onClick={remove}>{t('delete_budget')}</button>
</>}>
<p>{t('delete_budget_text', { dept: data.department, year: data.year })}</p>
<Field label={t('delete_budget_confirm', { year: data.year })}>
<input value={typed} onChange={(e) => setTyped(e.target.value)} autoFocus inputMode="numeric" />
</Field>
<ErrorNote error={error} />
</Modal>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { dateTime, eur, pct } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal, StatusBadge } from '../components/ui.jsx'
const FILTER_KEY = 'budget.deptFilter'
export default function Years() {
const t = useT()
const { user, lang } = useAuth()
const [years, setYears] = useState(null)
const [depts, setDepts] = useState([])
const [filter, setFilter] = useState(() => {
// Upper Management starts on all departments
const start = user.role === 'management' ? '' : String(user.department_id || '')
try { return localStorage.getItem(FILTER_KEY) ?? start } catch { return start }
})
const [dialog, setDialog] = useState(null)
const location = useLocation()
const [notice, setNotice] = useState(location.state?.notice || null)
const load = useCallback(() => api.get('/api/years').then(setYears), [])
const [archived, setArchived] = useState(null)
const showArchived = () => (archived ? setArchived(null) : api.get('/api/years?archived=true').then(setArchived))
useEffect(() => { load(); api.get('/api/departments').then(setDepts) }, [load])
useEffect(() => { try { localStorage.setItem(FILTER_KEY, filter) } catch { /* private mode */ } }, [filter])
// departments the user may create budgets in
const writable = atLeast(user, 'admin') ? depts.filter((d) => d.active)
: atLeast(user, 'editor') ? depts.filter((d) => d.id === user.department_id) : []
const shown = (years || []).filter((y) => !filter || String(y.department_id) === filter)
return (
<div>
<div className="page-head">
<h1>{t('years_title')}</h1>
<div className="actions">
{user.role !== 'management' && <button onClick={() => setDialog('template')}>{t('template')}</button>}
{writable.length > 0 && <>
<button onClick={() => setDialog('import')}>{t('import_xlsx')}</button>
<button className="primary" onClick={() => setDialog('new')}>{t('new_year')}</button>
</>}
</div>
</div>
<div className="toolbar">
<select value={filter} onChange={(e) => setFilter(e.target.value)} aria-label={t('department')}>
<option value="">{t('all_departments')}</option>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</div>
{user.role === 'management' && <p className="muted small">{t('management_scope')}</p>}
{notice && <div className="notice" onClick={() => setNotice(null)}>{notice}</div>}
{years && shown.length === 0 && <p className="muted">{t('no_years')}</p>}
<div className="year-grid">
{shown.map((y) => (
<Link to={`/years/${y.id}`} key={y.id} className="card year-card">
<div className="year-top"><span className="year-num">{y.year}</span><StatusBadge status={y.status} /></div>
{y.reopen_request && <div className="badge st-in_review reopen-badge">{t('reopen_requested_badge')}</div>}
<div className="dept-tag">{y.department}</div>
<div className="muted small">{y.title}</div>
<dl className="kv">
<dt>{t('prior_actual', { prior: y.year - 1 })}</dt><dd>{eur(y.costs.prior, lang, 0)}</dd>
<dt>{t('adjusted', { year: y.year })}</dt><dd className="strong">{eur(y.costs.adjusted, lang, 0)}</dd>
<dt>{t('change')}</dt><dd className={y.costs.change > 0 ? 'up' : 'down'}>{pct(y.costs.change_pct, lang)}</dd>
</dl>
</Link>
))}
</div>
<div className="archived-toggle">
<button className="link" onClick={showArchived}>{archived ? t('hide_archived') : t('show_archived')}</button>
</div>
{archived && (
archived.length === 0 ? <p className="muted small">{t('no_archived')}</p> : (
<div className="card">
<table className="plain">
<thead><tr><th>{t('department')}</th><th>{t('year')}</th><th>{t('title')}</th><th>{t('file')}</th><th>{t('archived_when')}</th><th className="right">{t('adjusted', { year: '' })}</th></tr></thead>
<tbody>{archived.filter((y) => !filter || String(y.department_id) === filter).map((y) => (
<tr key={y.id}><td>{y.department}</td><td>{y.year}</td><td><Link to={`/years/${y.id}`}>🔒 {y.title}</Link></td>
<td className="small muted">{y.source_file}</td><td className="small">{dateTime(y.archived_at, lang)} · {y.archived_by}</td>
<td className="right">{eur(y.costs.adjusted, lang, 0)}</td></tr>
))}</tbody>
</table>
</div>
)
)}
{dialog === 'new' && <NewYear years={years || []} depts={writable} initialDept={filter} onClose={() => setDialog(null)} />}
{dialog === 'import' && <ImportYear depts={writable} initialDept={filter} onClose={() => setDialog(null)} />}
{dialog === 'template' && <TemplateDialog depts={depts} years={years || []} initialDept={filter} onClose={() => setDialog(null)} />}
</div>
)
}
function pickDept(depts, initial, user) {
const ids = depts.map((d) => String(d.id))
if (ids.includes(initial)) return initial
if (ids.includes(String(user?.department_id))) return String(user.department_id)
return ids[0] || ''
}
function NewYear({ years, depts, initialDept, onClose }) {
const t = useT()
const { user } = useAuth()
const navigate = useNavigate()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const own = years.filter((y) => String(y.department_id) === deptId)
const latest = own[0]
const [form, setForm] = useState({})
useEffect(() => {
setForm({
year: latest ? latest.year + 1 : new Date().getFullYear() + 1,
default_rate: latest ? +(latest.default_rate * 100).toFixed(4) : 0,
copy_from_id: latest ? latest.id : '',
})
}, [deptId]) // eslint-disable-line react-hooks/exhaustive-deps
const [error, setError] = useState(null)
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async () => {
try {
const y = await api.post('/api/years', {
department_id: Number(deptId), year: Number(form.year),
default_rate: Number(String(form.default_rate).replace(',', '.')) / 100,
copy_from_id: form.copy_from_id ? Number(form.copy_from_id) : null,
})
navigate(`/years/${y.id}`)
} catch (e) { setError(e) }
}
return (
<Modal title={t('new_year')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('create')}</button></>}>
<Field label={t('department')}>
<select value={deptId} onChange={(e) => setDeptId(e.target.value)}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
<Field label={t('year')}><input type="number" value={form.year ?? ''} onChange={set('year')} /></Field>
<Field label={t('default_rate')}><input inputMode="decimal" value={form.default_rate ?? ''} onChange={set('default_rate')} /></Field>
<Field label={t('copy_from')} hint={form.copy_from_id ? t('copy_hint') : null}>
<select value={form.copy_from_id ?? ''} onChange={set('copy_from_id')}>
<option value="">{t('copy_none')}</option>
{own.map((y) => <option key={y.id} value={y.id}>{y.year} · {y.title}</option>)}
</select>
</Field>
<ErrorNote error={error} />
</Modal>
)
}
// Upload a budget file. When the department already has a budget for that year, ask first: the current
// version is renamed with the date and time and locked, and the upload becomes the current budget.
export function ImportYear({ depts, initialDept, onClose }) {
const t = useT()
const { user, lang } = useAuth()
const navigate = useNavigate()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const [file, setFile] = useState(null)
const [year, setYear] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [conflict, setConflict] = useState(null)
const form = (replace) => {
const fd = new FormData()
fd.append('file', file)
fd.append('department_id', deptId)
if (year) fd.append('year', year)
fd.append('replace', replace ? 'true' : 'false')
return fd
}
const upload = async (replace) => {
setBusy(true); setError(null)
try {
const res = await api.post('/api/years/import', form(replace))
const warn = res.warnings?.length ? ` (${res.warnings.length} ⚠: ${res.warnings.slice(0, 3).join('; ')}${res.warnings.length > 3 ? '…' : ''})` : ''
const archived = res.archived ? ' ' + t('import_archived', { title: res.archived.title }) : ''
navigate(`/years/${res.year.id}`, { state: { notice: t('import_done', { n: res.lines, year: res.year.year }) + archived + warn } })
onClose()
} catch (e) { setError(e) } finally { setBusy(false) }
}
const submit = async () => {
if (!file) return
setBusy(true); setError(null)
try {
const check = await api.post('/api/years/import/check', form(false))
if (check.existing) { setConflict(check); setBusy(false); return }
} catch (e) { setError(e); setBusy(false); return }
await upload(false)
}
if (conflict) {
const ex = conflict.existing
return (
<Modal title={t('file_exists_title')} onClose={onClose} footer={ex.can_replace
? <><button onClick={() => setConflict(null)}>{t('cancel')}</button><button className="primary" disabled={busy} onClick={() => upload(true)}>OK</button></>
: <button onClick={onClose}>{t('close')}</button>}>
<div className="confirm-box">
<p><strong>{t(ex.same_file ? 'file_exists_same' : 'file_exists_budget', { file: conflict.filename, dept: conflict.department, year: conflict.year })}</strong></p>
{ex.can_replace ? <>
<p>{t('file_exists_replace', { title: ex.title })}</p>
<dl className="kv wide">
<dt>{t('current_version')}</dt><dd>{ex.title}{ex.source_file && <span className="muted small"> · {ex.source_file}</span>}<span className="muted small"> · {eur(ex.costs.adjusted, lang)}</span></dd>
<dt>{t('renamed_to')}</dt><dd>{ex.archived_title}{ex.archived_file && <span className="muted small"> · {ex.archived_file}</span>} <span className="badge">{t('locked')}</span></dd>
</dl>
</> : <p className="error">{t('file_exists_locked', { status: t(`status_${ex.status}`).toLowerCase() })}</p>}
</div>
<ErrorNote error={error} />
</Modal>
)
}
return (
<Modal title={t('import_xlsx')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" disabled={!file || busy} onClick={submit}>{t('import')}</button></>}>
{depts.length > 1 && (
<Field label={t('department')}>
<select value={deptId} onChange={(e) => setDeptId(e.target.value)}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
)}
<Field label={t('file')} hint={t('template_hint')}><input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files[0])} /></Field>
<Field label={t('year')} hint={t('import_year_hint')}><input type="number" value={year} onChange={(e) => setYear(e.target.value)} /></Field>
<ErrorNote error={error} />
</Modal>
)
}
function TemplateDialog({ depts, years, initialDept, onClose }) {
const t = useT()
const { user, lang } = useAuth()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const [year, setYear] = useState(new Date().getFullYear() + 1)
const [rate, setRate] = useState('0')
const [tplLang, setTplLang] = useState(lang)
const [from, setFrom] = useState('')
const own = years.filter((y) => String(y.department_id) === deptId)
const q = new URLSearchParams({ lang: tplLang, year: String(year), rate: String(rate).replace(',', '.') || '0' })
if (from) q.set('from_year_id', from)
const href = `/api/departments/${deptId}/template.xlsx?${q}`
return (
<Modal title={t('template_title')} onClose={onClose}
footer={<><button onClick={onClose}>{t('close')}</button><a className="button primary-link" href={href} onClick={() => setTimeout(onClose, 300)}>{t('download')}</a></>}>
<p className="muted small">{t('template_hint')}</p>
<Field label={t('department')}>
<select value={deptId} onChange={(e) => { setDeptId(e.target.value); setFrom('') }}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
<div className="grid2">
<Field label={t('year')}><input type="number" value={year} onChange={(e) => setYear(e.target.value)} /></Field>
<Field label={t('year_rate_pct')}><input inputMode="decimal" value={rate} onChange={(e) => setRate(e.target.value)} /></Field>
</div>
<Field label={t('language')}>
<select value={tplLang} onChange={(e) => setTplLang(e.target.value)}><option value="pt">Português</option><option value="en">English</option></select>
</Field>
<Field label={t('prefill_from')}>
<select value={from} onChange={(e) => setFrom(e.target.value)}>
<option value="">{t('prefill_none')}</option>
{own.map((y) => <option key={y.id} value={y.id}>{y.year} · {y.title}</option>)}
</select>
</Field>
</Modal>
)
}
+281
View File
@@ -0,0 +1,281 @@
:root {
--bg: #f4f5f7; --surface: #ffffff; --surface-2: #f0f2f5; --text: #1d1f23; --muted: #6a6f78;
--border: #dcdfe4; --primary: #1f3b63; --primary-ink: #ffffff; --accent: #e8632b; --blue: #2f6fd6;
--up: #c2410c; --down: #15803d; --danger: #b42318; --focus: #2f6fd6; --notice: #eef4ff;
--shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .08);
color-scheme: light;
}
/* Dark colours: automatic from the computer unless the user picked light, or always when they picked dark */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #14161a; --surface: #1d2026; --surface-2: #252931; --text: #e8eaee; --muted: #9aa1ad;
--border: #343944; --primary: #5b8bd6; --primary-ink: #0d1117; --up: #fb923c; --down: #4ade80;
--danger: #f87171; --notice: #1f2a3d; --shadow: none; color-scheme: dark; }
}
:root[data-theme="dark"] { --bg: #14161a; --surface: #1d2026; --surface-2: #252931; --text: #e8eaee; --muted: #9aa1ad;
--border: #343944; --primary: #5b8bd6; --primary-ink: #0d1117; --up: #fb923c; --down: #4ade80;
--danger: #f87171; --notice: #1f2a3d; --shadow: none; color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
h1 { font-size: 22px; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: 16px; margin: 0; }
a { color: inherit; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.strong { font-weight: 600; }
.right { text-align: right; }
.center { justify-content: center; }
.nowrap { white-space: nowrap; }
.up { color: var(--up); } .down { color: var(--down); }
button, .button { font: inherit; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 7px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
button:hover, .button:hover { background: var(--surface-2); }
button.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); }
button.primary:hover { filter: brightness(1.08); }
button:disabled { opacity: .55; cursor: default; }
button.small { padding: 4px 10px; font-size: 13px; }
button.link { border: 0; background: none; padding: 4px 6px; color: var(--muted); }
button.icon { border: 0; background: none; padding: 2px 8px; font-size: 18px; line-height: 1; color: var(--muted); }
button.icon:hover { color: var(--text); background: var(--surface-2); }
button.icon.danger:hover { color: var(--danger); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border);
border-radius: 7px; padding: 7px 9px; width: 100%; }
input[type=checkbox] { width: auto; }
input:disabled, select:disabled, textarea:disabled { background: var(--surface-2); color: var(--muted); }
fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.shell { min-height: 100vh; }
.topbar { display: flex; align-items: center; gap: 24px; padding: 0 24px; height: 56px; background: var(--surface);
border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; white-space: nowrap; }
.brand.big { font-size: 18px; margin-bottom: 18px; }
.logo { display: inline-flex; align-items: flex-end; gap: 2px; width: 24px; height: 24px; padding: 4px; border-radius: 6px; background: var(--primary); }
.logo i { display: block; width: 4px; background: #fff; }
.logo i:nth-child(1) { height: 45%; } .logo i:nth-child(2) { height: 75%; background: var(--accent); } .logo i:nth-child(3) { height: 100%; }
.topbar nav { display: flex; gap: 4px; flex: 1; overflow-x: auto; }
.topbar nav a { padding: 8px 12px; border-radius: 8px; text-decoration: none; color: var(--muted); white-space: nowrap; }
.topbar nav a.active { color: var(--text); background: var(--surface-2); font-weight: 600; }
.userbox { display: flex; align-items: center; gap: 10px; }
.me { text-decoration: none; font-weight: 500; white-space: nowrap; }
.lang { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.lang button { border: 0; border-radius: 0; padding: 4px 9px; font-size: 12px; color: var(--muted); }
.lang button.on { background: var(--primary); color: var(--primary-ink); }
.content { max-width: 1440px; margin: 0 auto; padding: 24px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; box-shadow: var(--shadow); margin-bottom: 16px; }
.card.narrow { max-width: 480px; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.notice { background: var(--notice); border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; cursor: default; }
.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); border-radius: 8px; padding: 9px 12px; margin: 10px 0; }
.badge { font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 999px; background: var(--surface-2); color: var(--muted); white-space: nowrap; }
.badge.st-in_review { background: color-mix(in srgb, #d97706 18%, transparent); color: #b45309; }
.badge.st-approved { background: color-mix(in srgb, var(--down) 16%, transparent); color: var(--down); }
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.login { width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 12px; padding: 28px; }
.login h1 { font-size: 20px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.field-label { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.hint { font-size: 12px; color: var(--muted); }
.check { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.year-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.year-card { text-decoration: none; display: block; transition: border-color .15s; }
.year-card:hover { border-color: var(--primary); }
.year-top { display: flex; justify-content: space-between; align-items: center; }
.year-num { font-size: 26px; font-weight: 700; }
.kv { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; margin: 12px 0 0; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.totals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
.totals .card { margin-bottom: 0; }
.stat .k { font-size: 12.5px; color: var(--muted); }
.stat .v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
.stat .v small { font-size: 13px; font-weight: 600; }
.rate { display: inline-flex; align-items: center; gap: 4px; }
.rate input { width: 90px; font-size: 18px; font-weight: 700; padding: 2px 6px; }
.toolbar { display: flex; gap: 10px; margin: 4px 0 16px; align-items: center; flex-wrap: wrap; }
.toolbar input[type=search] { max-width: 320px; }
.toolbar select { width: auto; }
.cat { padding: 0; overflow: hidden; }
.cat-head { display: flex; align-items: center; gap: 16px; padding: 12px 16px; flex-wrap: wrap; }
.cat-head h2 { flex: 1; display: flex; gap: 10px; align-items: baseline; }
.cat-totals { display: flex; gap: 10px; align-items: baseline; font-variant-numeric: tabular-nums; }
.cat-totals .arrow { color: var(--muted); }
.table-wrap { overflow-x: auto; }
table.lines { width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 1160px; }
table.lines col.c-site { width: 120px; } table.lines col.c-amt { width: 135px; } table.lines col.c-adj { width: 200px; }
table.lines col.c-pct { width: 95px; } table.lines col.c-act { width: 72px; }
table.lines th { font-size: 12px; font-weight: 600; color: var(--muted); text-align: left; padding: 6px 8px; background: var(--surface-2);
border-top: 1px solid var(--border); white-space: nowrap; }
table.lines td { padding: 4px 8px; border-top: 1px solid var(--border); vertical-align: middle; }
table.lines th.num, table.lines td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.lines td.name { overflow: hidden; }
.clip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-left: 7px; }
.cell { border-color: transparent; background: transparent; padding: 5px 6px; border-radius: 6px; }
.cell:hover { border-color: var(--border); }
.cell:focus { border-color: var(--focus); background: var(--surface); }
.cell.num { text-align: right; font-variant-numeric: tabular-nums; }
.cell.bad { border-color: var(--danger); }
.short { width: 90px !important; min-width: 0 !important; }
.adj { display: flex; gap: 6px; align-items: center; }
.adj select { width: auto; }
.row-actions { white-space: nowrap; text-align: right; }
.adj .short { width: 72px !important; }
.backdrop { position: fixed; inset: 0; background: rgba(15, 18, 24, .45); display: grid; place-items: center; padding: 16px; z-index: 20; }
.modal { background: var(--surface); border-radius: 14px; width: 100%; max-width: 480px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
.modal.wide { max-width: 860px; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 8px; }
.modal-body { padding: 8px 20px; overflow-y: auto; }
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
.months { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0 10px; }
.month-strip { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px; margin: 4px 0 14px; font-variant-numeric: tabular-nums; }
.month-strip div { display: flex; flex-direction: column; align-items: flex-end; background: var(--surface-2); border-radius: 6px; padding: 4px 6px; font-size: 12px; }
table.plain { width: 100%; border-collapse: collapse; }
table.plain th { text-align: left; font-size: 12px; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--border); }
table.plain td { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.inline-add { display: flex; gap: 8px; margin-top: 12px; max-width: 480px; }
.report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.report { display: flex; flex-direction: column; gap: 10px; }
.report p { margin: 0; flex: 1; }
.viewer { padding: 0; overflow: hidden; }
.viewer-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 14px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.viewer iframe { width: 100%; height: 78vh; border: 0; background: #ececee; display: block; }
@media (max-width: 900px) {
.totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.topbar { gap: 12px; padding: 0 16px; }
.content { padding: 16px; }
}
@media (max-width: 640px) {
.topbar { flex-wrap: wrap; height: auto; padding: 8px 16px; gap: 6px 12px; }
.brand { flex: 1; }
.me { display: none; }
.topbar nav { order: 3; flex: 0 0 100%; }
.grid2 { grid-template-columns: 1fr; }
.months { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.month-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.totals { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat .v { font-size: 16px; }
}
.dept-tag { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 6px;
background: color-mix(in srgb, var(--blue) 14%, transparent); color: var(--blue); margin: 2px 0 4px; }
h1 .dept-tag { font-size: 13px; margin: 0; }
.flow { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 0; margin: 0 0 14px; }
.flow li { font-size: 12.5px; padding: 4px 12px 4px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface); }
.flow li.done { color: var(--down); border-color: color-mix(in srgb, var(--down) 40%, transparent); }
.flow li.done::before { content: "✓ "; }
.flow li.now { color: var(--primary-ink); background: var(--primary); border-color: var(--primary); font-weight: 600; }
.flow li.now.done::before { content: "✓ "; }
.dept .cat-head { border-bottom: 1px solid var(--border); }
.dept-name { flex: 1; }
.cell.title { font-size: 16px; font-weight: 600; max-width: 360px; }
.dept-body { padding: 4px 16px 16px; }
.dept-body h3 { font-size: 14px; margin: 10px 0 6px; }
.steps { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 8px; }
.step { border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }
.step-row { display: flex; gap: 8px; align-items: center; }
.step-row input { flex: 1; }
.step-row select { width: auto; }
.step-num { width: 24px; height: 24px; border-radius: 50%; background: var(--surface-2); display: grid; place-items: center; font-weight: 600; font-size: 12px; flex: none; }
.user-picks { display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 8px 0 2px 32px; }
.pick { font-size: 13px; }
.cat-head.flush { padding: 0 0 10px; }
select.auto { width: auto; }
a.primary-link { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); }
@media (max-width: 640px) { .step-row { flex-wrap: wrap; } .user-picks { padding-left: 0; } }
/* v3: delete, tabs, logs, email */
button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
button.danger.solid { background: var(--danger); border-color: var(--danger); color: #fff; }
button:disabled { opacity: .55; cursor: not-allowed; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted); padding: 8px 14px; }
.tabs button.on { color: var(--text); border-bottom-color: var(--primary); font-weight: 600; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 16px; }
.mail-form h2 { font-size: 15px; margin: 0 0 12px; }
.mail-form .hint { margin: 4px 0 10px; }
.check.big { font-weight: 600; font-size: 15px; }
.sticky-foot { position: sticky; bottom: 0; background: var(--bg); padding: 12px 0; z-index: 2; }
.sticky-foot input[type=email] { width: 240px; }
.spacer { flex: 1; }
.toolbar.wrap label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
.toolbar input[type=date] { width: auto; }
.card-head { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.pager { display: flex; align-items: center; gap: 8px; }
table.log td { vertical-align: top; }
table.log tr.bad td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
table.log tr.clickable { cursor: pointer; }
table.log tr.clickable:hover td { background: var(--surface-2); }
table.log tr.detail td { background: var(--surface-2); }
.badge.st-failed { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.kv.wide { grid-template-columns: max-content 1fr; margin: 0 0 8px; }
.kv.wide dd { text-align: left; }
.bad-text { color: var(--danger); }
.mail-body { white-space: pre-wrap; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin: 0; max-height: 260px; overflow: auto; }
.changes { margin: 4px 0 0; padding-left: 18px; color: var(--muted); }
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; }
@media (max-width: 760px) {
.grid3, .choices { grid-template-columns: 1fr; }
.sticky-foot input[type=email] { width: 100%; }
table.log { display: block; overflow-x: auto; }
}
/* v4: split over chosen months */
.month-pick { margin: 4px 0 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
.chip { min-width: 52px; padding: 5px 10px; border-radius: 999px; }
.chip.on { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); font-weight: 600; }
.chips.quick { gap: 2px; margin-top: 0; }
.month-strip div.off { opacity: .45; }
/* v5: download menu, reopen requests, archived versions */
details.menu { position: relative; }
details.menu > summary { list-style: none; cursor: pointer; }
details.menu > summary::-webkit-details-marker { display: none; }
.menu-list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 10; min-width: 270px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(16, 24, 40, .14); padding: 6px; display: flex; flex-direction: column; }
.menu-list a { padding: 8px 10px; border-radius: 6px; text-decoration: none; color: var(--text); display: flex; flex-direction: column; }
.menu-list a:hover { background: var(--surface-2); }
.reopen-note { border-left: 3px solid var(--accent); }
.reopen-note .quote { margin: 6px 0 8px; white-space: pre-wrap; }
.locked-note { border-left: 3px solid var(--muted); }
.source-file { margin: -6px 0 12px; }
.reopen-badge { display: inline-block; margin: 4px 0; }
h3.sub { font-size: 14px; margin: 4px 0 8px; }
h3.sub + table { margin-bottom: 16px; }
.confirm-box p { margin: 0 0 10px; }
.archived-toggle { margin: 16px 0 8px; }
/* v7: theme switch, profile pictures, MFA */
button.theme-btn { border: 1px solid var(--border); border-radius: 8px; padding: 4px 7px; line-height: 0; color: var(--muted); }
.login-tools { display: flex; justify-content: center; align-items: center; gap: 10px; }
table.plain .avatar { width: 24px; height: 24px; font-size: 10px; margin: -4px 0; }
.avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: none; display: inline-flex; align-items: center;
justify-content: center; background: var(--surface-2); color: var(--muted); font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
.avatar.lg { width: 88px; height: 88px; font-size: 30px; }
.me { display: inline-flex; align-items: center; gap: 8px; }
.namecell { display: inline-flex; align-items: center; gap: 10px; }
.avatar-row { display: flex; align-items: center; gap: 18px; margin: 6px 0 14px; flex-wrap: wrap; }
.avatar-row .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.section-title { font-size: 15px; margin: 22px 0 8px; padding-top: 16px; border-top: 1px solid var(--border); }
.badge.mfa-on { background: color-mix(in srgb, var(--down) 16%, transparent); color: var(--down); }
.qr { background: #fff; padding: 8px; border-radius: 8px; display: inline-block; line-height: 0; }
.qr svg { width: 200px; height: 200px; }
.secret { font-family: ui-monospace, Menlo, Consolas, monospace; letter-spacing: 1px; word-break: break-all; background: var(--surface-2);
padding: 6px 8px; border-radius: 6px; display: block; }
.codes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 15px; background: var(--surface-2); padding: 12px 16px; border-radius: 8px; margin: 10px 0; }
input.code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; letter-spacing: 4px; text-align: center; }
@media (max-width: 720px) { .me .me-name { display: none; } }
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8000' } },
})