Unzip budget-app v7 into repo root
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
backups/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
**/__pycache__/
|
||||||
|
backend/tests/test.db
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
7.0.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
tests
|
||||||
|
**/__pycache__
|
||||||
|
*.db
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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}")
|
||||||
@@ -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")
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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) }} → {{ t.eur(costs.adjusted, 0) }}</div>
|
||||||
|
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} ({{ 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"'})
|
||||||
@@ -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}"'})
|
||||||
@@ -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)
|
||||||
@@ -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"'})
|
||||||
@@ -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)})
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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.
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Executable
+48
@@ -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
|
||||||
Executable
+7
@@ -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"
|
||||||
@@ -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:
|
||||||
@@ -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 & 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 & 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://<server>: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&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&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/<file></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 & Capex · version 7.0.0 · guide updated 29 October 2026.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 & Capex</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1732
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 ?? ''))
|
||||||
@@ -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)
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} <{m.user_email}></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()} />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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' } },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user