Unzip budget-app v7 into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:40:02 +00:00
parent 2554e68315
commit c9fd26f1c7
94 changed files with 12098 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+32
View File
@@ -0,0 +1,32 @@
{
admin off
}
{$SITE_ADDRESS::80} {
encode zstd gzip
handle /api/* {
reverse_proxy api:8000 {
transport http {
read_timeout 120s
}
}
}
handle {
root * /srv/web
try_files {path} /index.html
file_server
}
header {
X-Content-Type-Options nosniff
Referrer-Policy same-origin
X-Frame-Options SAMEORIGIN
-Server
}
request_body {
max_size 25MB
}
}
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /web
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY index.html vite.config.js ./
COPY src ./src
RUN npm run build
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /web/dist /srv/web
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231f3b63'/><rect x='7' y='16' width='4' height='9' fill='%23fff'/><rect x='14' y='10' width='4' height='15' fill='%23e8632b'/><rect x='21' y='6' width='4' height='19' fill='%23fff'/></svg>" />
<title>Budget &amp; Capex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "budget-capex-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.4",
"vite": "5.4.11"
}
}
+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { api } from './api.js'
import { AuthContext, atLeast } from './auth.js'
import { I18nContext, makeT } from './i18n.js'
import Layout from './components/Layout.jsx'
import Login from './pages/Login.jsx'
import Years from './pages/Years.jsx'
import YearEditor from './pages/YearEditor.jsx'
import Reports from './pages/Reports.jsx'
import Users from './pages/Users.jsx'
import Settings from './pages/Settings.jsx'
import Profile from './pages/Profile.jsx'
import Departments from './pages/Departments.jsx'
import Activity from './pages/Activity.jsx'
import Mail from './pages/Mail.jsx'
const LANG_KEY = 'budget.lang'
const THEME_KEY = 'budget.theme'
const THEMES = ['system', 'light', 'dark']
function storedLang() {
try {
return localStorage.getItem(LANG_KEY) || 'pt'
} catch {
return 'pt'
}
}
function storedTheme() {
try {
return localStorage.getItem(THEME_KEY) || 'system'
} catch {
return 'system'
}
}
export default function App() {
const [user, setUser] = useState(undefined)
const [lang, setLangState] = useState(storedLang)
const [theme, setThemeState] = useState(storedTheme)
useEffect(() => {
api.get('/api/auth/me').then((u) => {
setUser(u)
setLangState(u.language)
if (THEMES.includes(u.theme)) setThemeState(u.theme)
}).catch(() => setUser(null))
const onLogout = () => setUser(null)
window.addEventListener('budget:logout', onLogout)
return () => window.removeEventListener('budget:logout', onLogout)
}, [])
useEffect(() => {
document.documentElement.lang = lang
try {
localStorage.setItem(LANG_KEY, lang)
} catch { /* private mode */ }
}, [lang])
// system: follow the computer; light / dark: always
useEffect(() => {
const root = document.documentElement
if (theme === 'system') delete root.dataset.theme
else root.dataset.theme = theme
try {
localStorage.setItem(THEME_KEY, theme)
} catch { /* private mode */ }
}, [theme])
const setTheme = useCallback((next) => {
setThemeState(next)
if (user) {
setUser((u) => u && { ...u, theme: next })
api.put('/api/auth/me', { theme: next }).catch(() => {})
}
}, [user])
const setLang = useCallback((next) => {
setLangState(next)
if (user) api.put('/api/auth/me', { language: next }).catch(() => {})
}, [user])
const t = useMemo(() => makeT(lang), [lang])
const auth = useMemo(() => ({ user, setUser, lang, setLang, theme, setTheme }), [user, lang, setLang, theme, setTheme])
if (user === undefined) return null
return (
<I18nContext.Provider value={t}>
<AuthContext.Provider value={auth}>
{!user ? (
<Login onLogin={(u) => { setUser(u); setLangState(u.language); if (THEMES.includes(u.theme)) setThemeState(u.theme) }} />
) : (
<Layout>
<Routes>
<Route path="/" element={<Years />} />
<Route path="/years/:id" element={<YearEditor />} />
<Route path="/reports" element={<Reports />} />
<Route path="/profile" element={<Profile />} />
{atLeast(user, 'admin') && <Route path="/users" element={<Users />} />}
{atLeast(user, 'approver') && <Route path="/settings" element={<Settings />} />}
{atLeast(user, 'admin') && <Route path="/departments" element={<Departments />} />}
{atLeast(user, 'admin') && <Route path="/activity" element={<Activity />} />}
{atLeast(user, 'admin') && <Route path="/mail" element={<Mail />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
)}
</AuthContext.Provider>
</I18nContext.Provider>
)
}
+34
View File
@@ -0,0 +1,34 @@
export class ApiError extends Error {
constructor(status, code) {
super(code)
this.status = status
this.code = code
}
}
async function request(method, url, body) {
const opts = { method, credentials: 'same-origin', headers: { 'X-Requested-With': 'budget-app' } }
if (body instanceof FormData) {
opts.body = body
} else if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
const res = await fetch(url, opts)
if (res.status === 401 && !url.endsWith('/auth/login')) {
window.dispatchEvent(new Event('budget:logout'))
}
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
if (!res.ok) {
const detail = data?.detail
throw new ApiError(res.status, typeof detail === 'string' ? detail : JSON.stringify(detail))
}
return data
}
export const api = {
get: (url) => request('GET', url),
post: (url, body) => request('POST', url, body ?? {}),
put: (url, body) => request('PUT', url, body),
del: (url) => request('DELETE', url),
}
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from 'react'
// same order as the server: Upper Management ('management') only views, downloads and emails
export const ROLES = ['viewer', 'management', 'editor', 'approver', 'admin']
export const AuthContext = createContext(null)
export const useAuth = () => useContext(AuthContext)
export const atLeast = (user, role) => !!user && ROLES.indexOf(user.role) >= ROLES.indexOf(role)
export const THEMES = ['system', 'light', 'dark']
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal } from './ui.jsx'
import { SendResult } from '../pages/Mail.jsx'
const CHOICES = [['cost-report', 'pdf'], ['cost-report', 'xlsx'], ['budget', 'pdf'], ['budget', 'xlsx'], ['workbook', 'xlsx']]
// Sends the chosen reports of one budget, with the language and site picked on the Reports page.
export default function EmailDialog({ year, initial, reportLang, siteId, siteName, includeEmpty, onClose }) {
const t = useT()
const { user } = useAuth()
const [to, setTo] = useState('')
const [cc, setCc] = useState('')
const [copyMe, setCopyMe] = useState(false)
const [subject, setSubject] = useState(`${year.department} ${year.year} — ${year.title}`)
const [message, setMessage] = useState(t('email_default_message', { title: year.title, dept: year.department, name: user.name }))
const [picked, setPicked] = useState(() => new Set(initial ? [initial.join('.')] : ['cost-report.pdf']))
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(null)
const toggle = (key) => {
const next = new Set(picked)
next.has(key) ? next.delete(key) : next.add(key)
setPicked(next)
}
const send = async () => {
setBusy(true); setError(null)
try {
const log = await api.post(`/api/years/${year.id}/email`, {
to: [to], cc: cc ? [cc] : [], copy_me: copyMe, subject, message, lang: reportLang,
site_id: siteId ? Number(siteId) : null, include_empty: includeEmpty,
reports: [...picked].map((k) => { const [kind, fmt] = k.split('.'); return { kind, fmt } }),
})
setDone(log)
} catch (e) { setError(e) } finally { setBusy(false) }
}
const name = (kind) => (kind === 'budget' ? t('report_budget') : kind === 'workbook' ? t('budget_file') : t('report_cost'))
return (
<Modal title={t('email_title')} onClose={onClose} footer={done
? <button className="primary" onClick={onClose}>{t('close')}</button>
: <>
<button onClick={onClose}>{t('cancel')}</button>
<button className="primary" disabled={busy || !to.trim() || picked.size === 0} onClick={send}>{busy ? t('email_sending') : t('email_send')}</button>
</>}>
{done ? <SendResult log={done} /> : <>
<p className="muted small">{year.department} · {year.year} · {t('email_options', { lang: reportLang === 'en' ? 'English' : 'Português', site: siteName || t('all_sites') })}</p>
<Field label={t('email_to')} hint={t('email_to_hint')}><input value={to} onChange={(e) => setTo(e.target.value)} placeholder="nome@empresa.pt" autoFocus /></Field>
<Field label={t('email_cc')}><input value={cc} onChange={(e) => setCc(e.target.value)} /></Field>
<label className="check"><input type="checkbox" checked={copyMe} onChange={(e) => setCopyMe(e.target.checked)} /> {t('email_copy_me')} <span className="muted small">({user.email})</span></label>
<Field label={t('email_subject')}><input value={subject} onChange={(e) => setSubject(e.target.value)} maxLength={300} /></Field>
<Field label={t('email_message')}><textarea rows={6} value={message} onChange={(e) => setMessage(e.target.value)} /></Field>
<div className="field">
<span className="field-label">{t('email_attachments')}</span>
<div className="choices">
{CHOICES.map(([kind, fmt]) => (
<label key={kind + fmt} className="check"><input type="checkbox" checked={picked.has(`${kind}.${fmt}`)} onChange={() => toggle(`${kind}.${fmt}`)} /> {name(kind)}{kind !== 'workbook' && ` · ${fmt === 'pdf' ? 'PDF' : 'Excel'}`}</label>
))}
</div>
</div>
<ErrorNote error={error} />
</>}
</Modal>
)
}
@@ -0,0 +1,44 @@
import { NavLink } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { Avatar, ThemeButton } from './ui.jsx'
export default function Layout({ children }) {
const t = useT()
const { user, setUser, lang, setLang } = useAuth()
const logout = async () => {
await api.post('/api/auth/logout').catch(() => {})
setUser(null)
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="logo" aria-hidden="true"><i /><i /><i /></span>
{t('app_name')}
</div>
<nav>
<NavLink to="/" end>{t('nav_years')}</NavLink>
<NavLink to="/reports">{t('nav_reports')}</NavLink>
{atLeast(user, 'admin') && <NavLink to="/departments">{t('nav_departments')}</NavLink>}
{atLeast(user, 'approver') && <NavLink to="/settings">{atLeast(user, 'admin') ? t('nav_settings') : t('nav_categories')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/users">{t('nav_users')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/activity">{t('nav_activity')}</NavLink>}
{atLeast(user, 'admin') && <NavLink to="/mail">{t('nav_mail')}</NavLink>}
</nav>
<div className="userbox">
<div className="lang" role="group" aria-label={t('language')}>
{['pt', 'en'].map((l) => (
<button key={l} className={l === lang ? 'on' : ''} onClick={() => setLang(l)}>{l.toUpperCase()}</button>
))}
</div>
<ThemeButton />
<NavLink to="/profile" className="me" title={user.name}><Avatar user={user} /><span className="me-name">{user.name}{user.department && <span className="muted small"> · {user.department}</span>}</span></NavLink>
<button className="link" onClick={logout}>{t('sign_out')}</button>
</div>
</header>
<main className="content">{children}</main>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect } from 'react'
import { THEMES, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
export function Modal({ title, onClose, children, footer, wide }) {
useEffect(() => {
const onKey = (e) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className={'modal' + (wide ? ' wide' : '')} role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-head">
<h2>{title}</h2>
<button className="icon" onClick={onClose} aria-label="close">×</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
)
}
export function Field({ label, hint, children }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
{hint && <span className="hint">{hint}</span>}
</label>
)
}
export function ErrorNote({ error }) {
const t = useT()
if (!error) return null
return <div className="error" role="alert">{t.error(error.code || error.message)}</div>
}
export function StatusBadge({ status }) {
const t = useT()
return <span className={`badge st-${status}`}>{t(`status_${status}`)}</span>
}
export function Change({ value, pctValue, fmt }) {
const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''
return <span className={cls}>{fmt(value, pctValue)}</span>
}
// a user's picture, or their initials when they have none
export function Avatar({ user, large }) {
const cls = 'avatar' + (large ? ' lg' : '')
if (user?.avatar_url) return <img className={cls} src={user.avatar_url} alt="" />
const initials = (user?.name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('')
return <span className={cls} aria-hidden="true">{initials}</span>
}
export function ThemeButton() {
const t = useT()
const { theme, setTheme } = useAuth()
const next = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]
return (
<button type="button" className="icon theme-btn" onClick={() => setTheme(next)}
title={`${t('theme')}: ${t('theme_' + theme)} · ${t('theme_switch')}`} aria-label={`${t('theme')}: ${t('theme_' + theme)}`}>
<ThemeIcon theme={theme} />
</button>
)
}
function ThemeIcon({ theme }) {
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2,
strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }
if (theme === 'light') return (
<svg {...common}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
)
if (theme === 'dark') return <svg {...common}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" /></svg>
}
+25
View File
@@ -0,0 +1,25 @@
const cache = {}
function nf(lang, opts) {
const key = lang + JSON.stringify(opts)
// 'always' groups 4-digit amounts too (pt-PT would otherwise print 9888,00 but 19 888,00)
if (!cache[key]) cache[key] = new Intl.NumberFormat(lang === 'en' ? 'en-IE' : 'pt-PT', { useGrouping: 'always', ...opts })
return cache[key]
}
export const eur = (v, lang, digits = 2) =>
v == null ? '—' : nf(lang, { style: 'currency', currency: 'EUR', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v)
export const num = (v, lang, digits = 2) =>
v == null ? '—' : nf(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v)
export const pct = (v, lang) =>
v == null ? '—' : (v > 0 ? '+' : '') + nf(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v) + '%'
export const signedEur = (v, lang) => (v == null ? '—' : (v > 0 ? '+' : '') + eur(v, lang))
export const dateTime = (iso, lang) =>
iso ? new Date(iso).toLocaleString(lang === 'en' ? 'en-GB' : 'pt-PT', { dateStyle: 'short', timeStyle: 'short' }) : ''
// Shows a stored expression in the user's decimal style: "839.67*12" -> "839,67*12" in Portuguese.
export const exprForDisplay = (expr, lang) => (lang === 'pt' ? String(expr ?? '').replace(/\./g, ',') : String(expr ?? ''))
+865
View File
@@ -0,0 +1,865 @@
import { createContext, useContext } from 'react'
const pt = {
app_name: 'Orçamento & Capex',
login_title: 'Entrar',
email: 'Email',
password: 'Palavra-passe',
sign_in: 'Entrar',
sign_out: 'Sair',
invalid_credentials: 'Email ou palavra-passe incorretos.',
nav_years: 'Orçamentos',
nav_reports: 'Relatórios',
nav_users: 'Utilizadores',
nav_settings: 'Categorias e unidades',
nav_profile: 'O meu perfil',
years_title: 'Orçamentos anuais',
new_year: 'Novo ano',
import_xlsx: 'Importar Excel',
open: 'Abrir',
status_draft: 'Rascunho',
status_in_review: 'Em revisão',
status_approved: 'Aprovado',
prior_actual: '{prior} real',
base: '{year} sem ajuste',
adjusted: '{year} com ajuste',
change: 'Variação',
change_pct: 'Variação %',
total_costs: 'Total de custos',
revenue: 'Receitas',
no_years: 'Ainda não há orçamentos. Crie um ano novo ou importe o ficheiro Excel.',
year: 'Ano',
title: 'Título',
default_rate: 'Inflação + ajustes (%)',
copy_from: 'Copiar linhas de',
copy_none: 'Começar vazio',
copy_hint: 'O orçamento ajustado do ano copiado passa a ser o valor real e a base do novo ano. Substitua depois pelos valores reais.',
create: 'Criar',
cancel: 'Cancelar',
save: 'Guardar',
close: 'Fechar',
delete: 'Apagar',
edit: 'Editar',
confirm_delete: 'Apagar "{name}"?',
file: 'Ficheiro',
import_year_hint: 'Deixe vazio para usar o ano indicado no ficheiro.',
replace_existing: 'Substituir o ano se já existir (só rascunhos)',
import_done: 'Importadas {n} linhas para {year}.',
import: 'Importar',
search: 'Pesquisar linhas…',
all_sites: 'Todas as unidades',
group: 'Grupo',
site: 'Unidade',
line: 'Linha',
name: 'Nome',
description: 'Descrição',
remarks: 'Observações',
category: 'Categoria',
adjustment: 'Ajuste',
adj_year_rate: 'Taxa do ano',
adj_percent: '%',
adj_factor: 'Fator ×',
adj_none: 'Sem ajuste',
adj_reason: 'Motivo do ajuste',
months: 'Meses',
monthly_even: 'Dividir por 12',
monthly_manual: 'Valores mensais',
monthly_selected: 'Dividir pelos meses escolhidos',
monthly_pick: 'Meses',
monthly_pick_hint: 'O valor com ajuste é dividido em partes iguais por {n} meses ({months}). Os outros meses ficam a zero.',
monthly_pick_none: 'Escolha os meses em que o valor é gasto.',
quarter: '{n}.º trim.',
half: '{n}.º sem.',
clear: 'Limpar',
monthly_hint_manual: 'O total do ano é a soma dos meses.',
add_line: 'Adicionar linha',
show_empty: 'Mostrar categorias vazias',
lines_count: '{n} linhas',
lines_one: '1 linha',
submit_review: 'Enviar para revisão',
approve: 'Aprovar',
return_draft: 'Devolver para rascunho',
reopen: 'Reabrir',
locked_hint: 'Este orçamento está {status} e não pode ser editado.',
history: 'Histórico',
submitted_by: 'Enviado por {name}',
approved_by: 'Aprovado por {name}',
expr_hint: 'Pode escrever cálculos, ex.: 12*839,67+3292,56',
invalid_expression: 'Valor inválido',
reports_title: 'Relatórios',
report_cost: 'Capex e Custos',
report_cost_desc: 'Relatório de custos por categoria: real do ano anterior vs. ano com ajuste, com gráficos.',
report_budget: 'Orçamento mensal',
report_budget_desc: 'Orçamento por categoria e linha, de janeiro a dezembro, como a folha Budget IT. O Excel inclui também a folha de dados.',
view_online: 'Ver online',
download_pdf: 'PDF',
download_xlsx: 'Excel',
language: 'Idioma',
include_empty: 'Incluir categorias vazias',
users_title: 'Utilizadores',
new_user: 'Novo utilizador',
role: 'Perfil',
role_viewer: 'Leitura',
role_editor: 'Editor',
role_approver: 'Aprovador',
role_admin: 'Administrador',
role_management: 'Gestão de Topo',
management_scope: 'Gestão de Topo: vê todos os departamentos. Pode consultar orçamentos e relatórios, descarregá-los e enviá-los por e-mail.',
roles_hint: 'Leitura: ver e exportar. Gestão de Topo: ver, descarregar e enviar por e-mail orçamentos e relatórios de todos os departamentos, sem alterar nada. Editor: editar rascunhos do seu departamento e enviar para revisão. Aprovador: também aprova e gere as categorias do departamento. Administrador: tudo, incluindo departamentos, fluxos e utilizadores.',
active: 'Ativo',
inactive: 'Inativo',
new_password: 'Nova palavra-passe',
password_optional: 'Deixe vazio para manter',
current_password: 'Palavra-passe atual',
categories: 'Categorias',
sites: 'Unidades',
name_en: 'Nome (inglês)',
name_pt: 'Nome (português)',
section: 'Secção',
section_cost: 'Custo',
section_revenue: 'Receita',
order: 'Ordem',
new_category: 'Nova categoria',
new_site: 'Nova unidade',
saved: 'Guardado.',
profile_title: 'O meu perfil',
error: 'Ocorreu um erro: {msg}',
when: 'Quando',
who: 'Quem',
what: 'O quê',
nothing_yet: 'Sem registos.',
totals: 'Totais',
details: 'Detalhes',
nav_departments: 'Departamentos',
nav_categories: 'Categorias',
department: 'Departamento',
departments: 'Departamentos',
all_departments: 'Todos os departamentos',
new_department: 'Novo departamento',
categories_from: 'Categorias iniciais',
categories_defaults: 'Plano de contas padrão',
categories_none: 'Nenhuma',
categories_copy: 'Copiar de {name}',
copy_categories: 'Copiar categorias',
copy_categories_done: 'Categorias copiadas.',
workflow: 'Fluxo de aprovação',
workflow_hint: 'Depois de enviado, o orçamento passa por estes passos por ordem. Só o departamento do orçamento o pode editar; os outros departamentos só podem ver.',
workflow_default: 'Sem passos definidos: um passo, aprovado por qualquer aprovador do departamento.',
step_name: 'Nome do passo',
approver_mode: 'Quem aprova',
mode_role: 'Aprovadores do departamento',
mode_users: 'Utilizadores escolhidos',
add_step: 'Adicionar passo',
save_workflow: 'Guardar fluxo',
move_up: 'Subir',
move_down: 'Descer',
step_of: 'Passo {n} de {total}: {name}',
waiting_for: 'A aguardar: {name}',
view_only_dept: 'Só o departamento {dept} pode alterar e aprovar este orçamento.',
no_department: 'Sem departamento',
template: 'Modelo Excel',
template_title: 'Descarregar modelo Excel',
template_hint: 'Preencha o modelo no Excel e importe-o em Orçamentos > Importar Excel.',
prefill_from: 'Pré-preencher com as linhas de',
prefill_none: 'Modelo em branco',
download: 'Descarregar',
year_rate_pct: 'Taxa do ano (%)',
user_department_hint: 'Os utilizadores só podem editar e aprovar orçamentos do seu departamento. A Gestão de Topo vê todos os departamentos, com ou sem departamento.',
nav_activity: "Atividade",
search_log: "Pesquisar…",
nav_mail: "Email",
delete_budget: "Apagar orçamento",
delete_budget_text: "Vai apagar o orçamento {dept} {year} e todas as suas linhas. Os relatórios deste orçamento deixam de estar disponíveis para todos. Não é possível desfazer. O registo de atividade guarda quem o apagou.",
delete_budget_confirm: "Escreva {year} para confirmar",
budget_deleted: "O orçamento {dept} {year} foi apagado.",
send_email: "Enviar por email",
email_title: "Enviar relatórios por email",
email_to: "Para",
email_cc: "Cc",
email_to_hint: "Separe vários endereços com vírgulas.",
email_copy_me: "Enviar-me uma cópia",
email_subject: "Assunto",
email_message: "Mensagem",
email_attachments: "Anexos",
email_send: "Enviar",
email_sending: "A enviar…",
email_options: "Relatórios em {lang} · {site}",
email_default_message: "Bom dia,\n\nSegue em anexo o {title} ({dept}).\n\nCumprimentos,\n{name}",
email_sent: "Email enviado para {to}.",
email_copy_saved: "Cópia guardada na pasta \"{folder}\".",
email_copy_failed: "O email foi enviado, mas não foi possível guardar a cópia na pasta Enviados ({detail}).",
email_not_configured: "O envio por email ainda não está configurado.",
email_configure: "Configurar email",
mail_title: "Email",
mail_tab_account: "Conta de envio",
mail_tab_log: "Registo de emails",
mail_enabled: "Envio por email ativo",
mail_enabled_hint: "Quando ativo, todos os utilizadores podem enviar relatórios por email a partir da página Relatórios.",
mail_smtp: "Servidor de envio (SMTP)",
host: "Servidor",
port: "Porta",
security: "Segurança",
security_starttls: "STARTTLS (normalmente porta 587)",
security_ssl: "SSL/TLS (normalmente 465 no SMTP, 993 no IMAP)",
security_none: "Sem encriptação (só em redes de confiança)",
username: "Utilizador",
password_keep_hint: "Deixe em branco para manter a palavra-passe guardada.",
password_saved: "guardada",
mail_sender: "Remetente",
from_address: "Endereço do remetente",
from_name: "Nome do remetente",
reply_to: "Responder para (opcional)",
signature: "Assinatura (opcional)",
mail_sent_copy: "Cópia na pasta Enviados (IMAP)",
save_to_sent: "Guardar uma cópia de cada email enviado na pasta Enviados do servidor",
save_to_sent_hint: "O Gmail já guarda sozinho uma cópia dos emails enviados por SMTP. Com o Gmail, desligue esta opção para não ficar com cópias repetidas.",
imap_host_hint: "Vazio: usa o mesmo servidor do SMTP.",
imap_same_login: "Usar o mesmo utilizador e palavra-passe do SMTP",
sent_folder: "Pasta Enviados",
sent_folder_hint: "Vazio: é encontrada automaticamente (Sent, Enviados, Itens Enviados…).",
mail_check: "Testar ligação",
mail_test_send: "Enviar email de teste",
mail_test_to: "Endereço para o teste",
mail_check_smtp: "SMTP: {result}",
mail_check_imap: "IMAP: {result}",
mail_check_folder: "Pasta Enviados: \"{folder}\"",
ok: "OK",
mail_saved: "Definições guardadas.",
mail_save_first: "Guarde as definições antes de testar.",
last_changed: "Alterado por {name} · {when}",
log_sent: "Enviado",
log_failed: "Falhou",
copy_saved: "Cópia guardada",
copy_failed: "Cópia falhou",
copy_off: "Sem cópia",
from_date: "De",
to_date: "Até",
all_users: "Todos os utilizadores",
all_statuses: "Todos os estados",
all_areas: "Todas as áreas",
all_actions: "Todas as ações",
export_csv: "Exportar CSV",
results_count: "{n} registos",
prev: "Anterior",
next: "Seguinte",
details: "Detalhes",
activity_title: "Registo de atividade",
activity_hint: "Tudo o que os utilizadores fazem: entradas na aplicação, alterações a orçamentos e linhas, aprovações, importações, relatórios vistos, descarregados e enviados, e alterações de administração.",
mail_log_hint: "Todos os emails enviados pela aplicação: quem enviou, para quem, o quê e se a cópia ficou na pasta Enviados.",
col_area: "Área",
col_action: "Ação",
col_budget: "Orçamento",
col_to: "Para",
col_subject: "Assunto",
col_status: "Estado",
col_copy: "Pasta Enviados",
col_attachments: "Anexos",
col_from: "De",
deleted: "apagado",
test_email: "teste",
area_user: "Utilizador",
area_year: "Orçamento",
area_line: "Linha",
area_report: "Relatório",
area_mail: "Email",
area_mail_settings: "Definições de email",
area_category: "Categoria",
area_site: "Unidade",
area_department: "Departamento",
area_template: "Modelo Excel",
act_create: "criou",
act_update: "alterou",
act_delete: "apagou",
act_login: "entrou",
act_logout: "saiu",
act_login_failed: "falhou a entrada",
act_password_changed: "mudou a palavra-passe",
act_import: "importou",
act_submit: "submeteu",
act_approve: "aprovou",
act_return: "devolveu",
act_reopen: "reabriu",
act_view: "viu",
act_download: "descarregou",
act_send: "enviou",
act_send_failed: "envio falhou",
act_check: "testou a ligação",
act_workflow: "mudou o fluxo",
act_copy_categories: "copiou categorias",
upload_excel: "Carregar Excel",
request_reopen: "Pedir reabertura",
send_request: "Enviar pedido",
archived_note: "Versão arquivada em {when} por {name}: foi substituída por um ficheiro carregado. Está bloqueada e já não pode ser alterada.",
reopen_pending: "{name} pediu a reabertura deste orçamento ({when}):",
reopen_accept: "Aceitar e reabrir",
reopen_decline: "Recusar",
loaded_from: "Carregado do ficheiro {file}",
reopened_notice: "O orçamento foi reaberto e está de novo em rascunho.",
reopen_sent_notice: "Pedido enviado. Os aprovadores do departamento vão decidir.",
budget_file: "Ficheiro do orçamento (Excel editável)",
budget_file_hint: "Altere e volte a carregar",
reopen_direct_hint: "O orçamento volta a rascunho. O motivo fica no histórico.",
reopen_request_hint: "Os aprovadores do departamento recebem o pedido e decidem se o orçamento volta a rascunho.",
decline_reason: "Motivo da recusa",
reopen_reason: "Motivo",
reason_min: "Obrigatório (pelo menos 5 caracteres).",
reopen_requests: "Pedidos de reabertura",
changes: "Alterações",
req_pending: "Pendente",
req_accepted: "Aceite",
req_declined: "Recusado",
import_archived: "A versão anterior foi guardada como \"{title}\" e bloqueada.",
file_exists_title: "O ficheiro já existe",
file_exists_same: "O ficheiro {file} já existe na aplicação ({dept} {year}).",
file_exists_budget: "Já existe um orçamento {dept} {year} na aplicação.",
file_exists_replace: "Os dados atuais vão ser substituídos pelos do ficheiro. A versão atual não é apagada: fica guardada com a data e hora no nome e bloqueada para alterações.",
current_version: "Versão atual",
renamed_to: "Passa a chamar-se",
locked: "bloqueada",
file_exists_locked: "Não é possível substituir: o orçamento está {status}. Peça a reabertura primeiro.",
reopen_requested_badge: "Reabertura pedida",
show_archived: "Mostrar versões arquivadas",
hide_archived: "Esconder versões arquivadas",
no_archived: "Não há versões arquivadas.",
archived_when: "Arquivado",
archived_badge: "Arquivado",
act_archive: "arquivou",
act_reopen_requested: "pediu reabertura",
act_reopen_declined: "recusou reabertura",
theme: 'Aspeto',
theme_system: 'Automático (do computador)',
theme_light: 'Claro',
theme_dark: 'Escuro',
theme_switch: 'Mudar entre claro, escuro e automático',
avatar: 'Fotografia',
avatar_upload: 'Escolher fotografia',
avatar_remove: 'Remover',
avatar_hint: 'JPG, PNG, GIF ou WebP até 5 MB. É recortada em quadrado e reduzida a 256 px.',
security: 'Segurança',
change_password: 'Mudar a palavra-passe',
mfa: 'Autenticação em dois passos (MFA)',
mfa_short: 'MFA',
mfa_on: 'Ativa',
mfa_off: 'Inativa',
mfa_on_since: 'Ativa desde {when}. Restam {n} códigos de recuperação.',
mfa_off_text: 'Além da palavra-passe, pede um código de 6 dígitos de uma aplicação no telemóvel (Microsoft Authenticator, Google Authenticator, Authy…).',
mfa_setup: 'Ativar MFA',
mfa_disable: 'Desativar MFA',
mfa_new_codes: 'Novos códigos de recuperação',
mfa_step1: '1. Abra a aplicação de autenticação e leia este código QR.',
mfa_manual: 'Ou escreva esta chave na aplicação:',
mfa_step2: '2. Escreva o código de 6 dígitos que a aplicação mostra.',
mfa_code: 'Código',
mfa_confirm: 'Confirmar e ativar',
mfa_codes_title: 'Guarde os códigos de recuperação',
mfa_codes_text: 'Cada código serve uma vez, se ficar sem o telemóvel. Guarde-os num local seguro: não voltam a ser mostrados.',
mfa_codes_copy: 'Copiar',
mfa_codes_done: 'Já guardei os códigos',
mfa_disable_text: 'Para desativar, escreva a palavra-passe e um código da aplicação (ou um código de recuperação).',
mfa_new_codes_text: 'Os códigos antigos deixam de funcionar. Escreva um código da aplicação para continuar.',
mfa_login_title: 'Código de verificação',
mfa_login_text: 'Abra a aplicação de autenticação e escreva o código de 6 dígitos. Sem o telemóvel, use um código de recuperação.',
mfa_back: 'Voltar',
mfa_verify: 'Verificar',
mfa_reset: 'Repor MFA',
mfa_reset_text: 'Desativa o MFA deste utilizador (por exemplo, se perdeu o telemóvel). Pode voltar a ativá-lo no seu perfil.',
mfa_reset_done: 'MFA desativado para este utilizador.',
act_mfa_enabled: 'ativou o MFA',
act_mfa_disabled: 'desativou o MFA',
act_mfa_reset: 'repôs o MFA',
act_mfa_failed: 'errou o código MFA',
act_mfa_recovery_used: 'usou um código de recuperação',
act_mfa_recovery_renewed: 'gerou novos códigos de recuperação',
act_avatar_changed: 'mudou a fotografia',
act_avatar_removed: 'removeu a fotografia',
errors: {
invalid_mfa_code: 'Código inválido ou já usado. Tente o código seguinte da aplicação.',
mfa_locked: 'Demasiados códigos errados. Aguarde 5 minutos e tente de novo.',
mfa_setup_first: 'Comece de novo a ativação do MFA.',
mfa_already_on: 'O MFA já está ativo.',
mfa_off: 'O MFA não está ativo.',
not_an_image: 'O ficheiro não é uma imagem.',
image_too_large: 'A imagem tem mais de 5 MB.',
reason_required: "Indique o motivo (pelo menos 5 caracteres).",
reopen_already_requested: "Já há um pedido de reabertura pendente.",
year_not_draft: "O orçamento não está em rascunho. Peça a reabertura primeiro.",
mail_not_configured: "O envio por email ainda não está configurado. Peça a um administrador.",
mail_incomplete: "Preencha o servidor SMTP e o endereço do remetente.",
invalid_address: "Endereço de email inválido.",
no_recipients: "Indique pelo menos um destinatário.",
too_many_recipients: "Demasiados destinatários (máximo 25).",
no_attachments: "Escolha pelo menos um relatório para anexar.",
smtp_auth: "O servidor de email recusou o utilizador ou a palavra-passe.",
smtp_connect: "Não foi possível ligar ao servidor de email. Verifique o servidor e a porta.",
smtp_tls: "Falhou a ligação segura (TLS) ao servidor de email. Verifique a opção de segurança.",
smtp_recipients: "O servidor recusou um ou mais destinatários.",
smtp_error: "O servidor de email devolveu um erro.",
invalid_message: "Não foi possível montar o email.",
password_unreadable: "A palavra-passe guardada já não pode ser lida (a SECRET_KEY mudou). Introduza-a de novo.",
invalid_security: "Opção de segurança inválida.",
no_months_selected: 'Escolha pelo menos um mês.',
invalid_date: "Data inválida.",
file_too_large: "O ficheiro é demasiado grande.",
invalid_department: 'Escolha um departamento válido.',
workflow_in_use: 'Há orçamentos deste departamento em revisão. Aprove-os ou devolva-os antes de mudar o fluxo.',
step_needs_approvers: 'Escolha pelo menos um utilizador para cada passo com utilizadores escolhidos.',
invalid_step: 'Cada passo precisa de um nome.',
template_header_missing: 'Não foi encontrada a linha de cabeçalhos do modelo.',
invalid_transition: 'O orçamento mudou entretanto. Atualize a página.',
year_exists: 'Esse ano já existe.',
year_not_draft: 'O orçamento não está em rascunho.',
missing_fields: 'Preencha os campos obrigatórios.',
not_an_xlsx: 'O ficheiro não é um Excel válido (.xlsx).',
missing_sheets: 'O ficheiro não tem as folhas "Sheet1" e "Budget IT".',
year_not_found: 'Não foi possível detetar o ano. Indique-o.',
email_taken: 'Esse email já está registado.',
password_too_short: 'A palavra-passe precisa de pelo menos 8 caracteres.',
wrong_password: 'A palavra-passe atual está errada.',
category_in_use: 'A categoria tem linhas e não pode ser apagada. Desative-a.',
name_taken: 'Esse nome já existe.',
forbidden: 'Não tem permissão para isto.',
cannot_demote_self: 'Não pode retirar o seu próprio acesso de administrador.',
},
}
const en = {
app_name: 'Budget & Capex',
login_title: 'Sign in',
email: 'Email',
password: 'Password',
sign_in: 'Sign in',
sign_out: 'Sign out',
invalid_credentials: 'Wrong email or password.',
nav_years: 'Budgets',
nav_reports: 'Reports',
nav_users: 'Users',
nav_settings: 'Categories and sites',
nav_profile: 'My profile',
years_title: 'Yearly budgets',
new_year: 'New year',
import_xlsx: 'Import Excel',
open: 'Open',
status_draft: 'Draft',
status_in_review: 'In review',
status_approved: 'Approved',
prior_actual: '{prior} actual',
base: '{year} without adjustment',
adjusted: '{year} with adjustment',
change: 'Change',
change_pct: 'Change %',
total_costs: 'Total costs',
revenue: 'Revenue',
no_years: 'No budgets yet. Create a new year or import the Excel file.',
year: 'Year',
title: 'Title',
default_rate: 'Inflation + adjustments (%)',
copy_from: 'Copy lines from',
copy_none: 'Start empty',
copy_hint: "The copied year's adjusted budget becomes the new year's actual and base. Replace them with real figures afterwards.",
create: 'Create',
cancel: 'Cancel',
save: 'Save',
close: 'Close',
delete: 'Delete',
edit: 'Edit',
confirm_delete: 'Delete "{name}"?',
file: 'File',
import_year_hint: 'Leave empty to use the year found in the file.',
replace_existing: 'Replace the year if it exists (drafts only)',
import_done: 'Imported {n} lines into {year}.',
import: 'Import',
search: 'Search lines…',
all_sites: 'All sites',
group: 'Group',
site: 'Site',
line: 'Line',
name: 'Name',
description: 'Description',
remarks: 'Remarks',
category: 'Category',
adjustment: 'Adjustment',
adj_year_rate: 'Year rate',
adj_percent: '%',
adj_factor: 'Factor ×',
adj_none: 'No adjustment',
adj_reason: 'Reason for adjustment',
months: 'Months',
monthly_even: 'Split over 12',
monthly_manual: 'Monthly amounts',
monthly_selected: 'Split over chosen months',
monthly_pick: 'Months',
monthly_pick_hint: 'The adjusted amount is split equally over {n} months ({months}). The other months stay at zero.',
monthly_pick_none: 'Choose the months the amount is spent in.',
quarter: 'Q{n}',
half: 'H{n}',
clear: 'Clear',
monthly_hint_manual: 'The year total is the sum of the months.',
add_line: 'Add line',
show_empty: 'Show empty categories',
lines_count: '{n} lines',
lines_one: '1 line',
submit_review: 'Submit for review',
approve: 'Approve',
return_draft: 'Return to draft',
reopen: 'Reopen',
locked_hint: 'This budget is {status} and cannot be edited.',
history: 'History',
submitted_by: 'Submitted by {name}',
approved_by: 'Approved by {name}',
expr_hint: 'You can type calculations, e.g. 12*839.67+3292.56',
invalid_expression: 'Invalid value',
reports_title: 'Reports',
report_cost: 'Capex and Costs',
report_cost_desc: "Cost report by category: last year's actual vs. the year with adjustment, with charts.",
report_budget: 'Monthly budget',
report_budget_desc: 'Budget by category and line, January to December, like the Budget IT sheet. The Excel file also includes the inputs sheet.',
view_online: 'View online',
download_pdf: 'PDF',
download_xlsx: 'Excel',
language: 'Language',
include_empty: 'Include empty categories',
users_title: 'Users',
new_user: 'New user',
role: 'Role',
role_viewer: 'Viewer',
role_editor: 'Editor',
role_approver: 'Approver',
role_admin: 'Administrator',
role_management: 'Upper Management',
management_scope: 'Upper Management: you see every department. You can view budgets and reports, download them and send them by email.',
roles_hint: "Viewer: view and export. Upper Management: view, download and email budgets and reports of every department, without changing anything. Editor: edit their department's drafts and submit them for review. Approver: can also approve and manage the department's categories. Administrator: everything, including departments, workflows and users.",
active: 'Active',
inactive: 'Inactive',
new_password: 'New password',
password_optional: 'Leave empty to keep',
current_password: 'Current password',
categories: 'Categories',
sites: 'Sites',
name_en: 'Name (English)',
name_pt: 'Name (Portuguese)',
section: 'Section',
section_cost: 'Cost',
section_revenue: 'Revenue',
order: 'Order',
new_category: 'New category',
new_site: 'New site',
saved: 'Saved.',
profile_title: 'My profile',
error: 'Something went wrong: {msg}',
when: 'When',
who: 'Who',
what: 'What',
nothing_yet: 'Nothing yet.',
totals: 'Totals',
details: 'Details',
nav_departments: 'Departments',
nav_categories: 'Categories',
department: 'Department',
departments: 'Departments',
all_departments: 'All departments',
new_department: 'New department',
categories_from: 'Starting categories',
categories_defaults: 'Default chart of accounts',
categories_none: 'None',
categories_copy: 'Copy from {name}',
copy_categories: 'Copy categories',
copy_categories_done: 'Categories copied.',
workflow: 'Approval workflow',
workflow_hint: "Once submitted, the budget goes through these steps in order. Only the budget's department can edit it; other departments can only view.",
workflow_default: 'No steps set: one step, approved by any approver of the department.',
step_name: 'Step name',
approver_mode: 'Who approves',
mode_role: 'Department approvers',
mode_users: 'Selected users',
add_step: 'Add step',
save_workflow: 'Save workflow',
move_up: 'Move up',
move_down: 'Move down',
step_of: 'Step {n} of {total}: {name}',
waiting_for: 'Waiting for: {name}',
view_only_dept: 'Only the {dept} department can change and approve this budget.',
no_department: 'No department',
template: 'Excel template',
template_title: 'Download Excel template',
template_hint: 'Fill in the template in Excel and import it under Budgets > Import Excel.',
prefill_from: 'Pre-fill with the lines of',
prefill_none: 'Blank template',
download: 'Download',
year_rate_pct: 'Year rate (%)',
user_department_hint: 'Users can only edit and approve budgets of their own department. Upper Management sees every department, with or without one.',
nav_activity: "Activity",
search_log: "Search…",
nav_mail: "Email",
delete_budget: "Delete budget",
delete_budget_text: "This deletes the {dept} {year} budget and all its lines. Its reports stop being available to everyone. It can't be undone. The activity log keeps who deleted it.",
delete_budget_confirm: "Type {year} to confirm",
budget_deleted: "The {dept} {year} budget was deleted.",
send_email: "Send by email",
email_title: "Send reports by email",
email_to: "To",
email_cc: "Cc",
email_to_hint: "Separate several addresses with commas.",
email_copy_me: "Send me a copy",
email_subject: "Subject",
email_message: "Message",
email_attachments: "Attachments",
email_send: "Send",
email_sending: "Sending…",
email_options: "Reports in {lang} · {site}",
email_default_message: "Hello,\n\nPlease find attached the {title} ({dept}).\n\nBest regards,\n{name}",
email_sent: "Email sent to {to}.",
email_copy_saved: "Copy saved in the \"{folder}\" folder.",
email_copy_failed: "The email was sent, but the copy could not be saved in the Sent folder ({detail}).",
email_not_configured: "Sending by email is not set up yet.",
email_configure: "Set up email",
mail_title: "Email",
mail_tab_account: "Sending account",
mail_tab_log: "Mail log",
mail_enabled: "Sending by email is on",
mail_enabled_hint: "When on, every user can email reports from the Reports page.",
mail_smtp: "Outgoing server (SMTP)",
host: "Server",
port: "Port",
security: "Security",
security_starttls: "STARTTLS (usually port 587)",
security_ssl: "SSL/TLS (usually 465 for SMTP, 993 for IMAP)",
security_none: "No encryption (trusted networks only)",
username: "Username",
password_keep_hint: "Leave empty to keep the saved password.",
password_saved: "saved",
mail_sender: "Sender",
from_address: "Sender address",
from_name: "Sender name",
reply_to: "Reply to (optional)",
signature: "Signature (optional)",
mail_sent_copy: "Copy in the Sent folder (IMAP)",
save_to_sent: "Save a copy of every email sent in the server's Sent folder",
save_to_sent_hint: "Gmail already keeps a copy of messages sent over SMTP. With Gmail, turn this off to avoid duplicate copies.",
imap_host_hint: "Empty: uses the SMTP server.",
imap_same_login: "Use the same username and password as SMTP",
sent_folder: "Sent folder",
sent_folder_hint: "Empty: found automatically (Sent, Sent Items, Enviados…).",
mail_check: "Test connection",
mail_test_send: "Send test email",
mail_test_to: "Address for the test",
mail_check_smtp: "SMTP: {result}",
mail_check_imap: "IMAP: {result}",
mail_check_folder: "Sent folder: \"{folder}\"",
ok: "OK",
mail_saved: "Settings saved.",
mail_save_first: "Save the settings before testing.",
last_changed: "Changed by {name} · {when}",
log_sent: "Sent",
log_failed: "Failed",
copy_saved: "Copy saved",
copy_failed: "Copy failed",
copy_off: "No copy",
from_date: "From",
to_date: "To",
all_users: "All users",
all_statuses: "All statuses",
all_areas: "All areas",
all_actions: "All actions",
export_csv: "Export CSV",
results_count: "{n} entries",
prev: "Previous",
next: "Next",
details: "Details",
activity_title: "Activity log",
activity_hint: "Everything users do: sign-ins, changes to budgets and lines, approvals, imports, reports viewed, downloaded and emailed, and admin changes.",
mail_log_hint: "Every email the app sent: who sent it, to whom, what, and whether the copy reached the Sent folder.",
col_area: "Area",
col_action: "Action",
col_budget: "Budget",
col_to: "To",
col_subject: "Subject",
col_status: "Status",
col_copy: "Sent folder",
col_attachments: "Attachments",
col_from: "From",
deleted: "deleted",
test_email: "test",
area_user: "User",
area_year: "Budget",
area_line: "Line",
area_report: "Report",
area_mail: "Email",
area_mail_settings: "Email settings",
area_category: "Category",
area_site: "Site",
area_department: "Department",
area_template: "Excel template",
act_create: "created",
act_update: "changed",
act_delete: "deleted",
act_login: "signed in",
act_logout: "signed out",
act_login_failed: "failed sign-in",
act_password_changed: "changed password",
act_import: "imported",
act_submit: "submitted",
act_approve: "approved",
act_return: "returned",
act_reopen: "reopened",
act_view: "viewed",
act_download: "downloaded",
act_send: "sent",
act_send_failed: "send failed",
act_check: "tested connection",
act_workflow: "changed workflow",
act_copy_categories: "copied categories",
upload_excel: "Upload Excel",
request_reopen: "Request reopening",
send_request: "Send request",
archived_note: "Archived version, {when} by {name}: it was replaced by an uploaded file. It is locked and can no longer be changed.",
reopen_pending: "{name} asked for this budget to be reopened ({when}):",
reopen_accept: "Accept and reopen",
reopen_decline: "Decline",
loaded_from: "Loaded from the file {file}",
reopened_notice: "The budget was reopened and is a draft again.",
reopen_sent_notice: "Request sent. The department's approvers will decide.",
budget_file: "Budget file (editable Excel)",
budget_file_hint: "Change it and upload it again",
reopen_direct_hint: "The budget goes back to draft. The reason is kept in the history.",
reopen_request_hint: "The department's approvers get the request and decide whether the budget goes back to draft.",
decline_reason: "Reason for declining",
reopen_reason: "Reason",
reason_min: "Required (at least 5 characters).",
reopen_requests: "Reopen requests",
changes: "Changes",
req_pending: "Pending",
req_accepted: "Accepted",
req_declined: "Declined",
import_archived: "The previous version was kept as \"{title}\" and locked.",
file_exists_title: "The file already exists",
file_exists_same: "The file {file} already exists in the app ({dept} {year}).",
file_exists_budget: "There is already a {dept} {year} budget in the app.",
file_exists_replace: "The current data will be replaced by the file. The current version is not deleted: it is kept with the date and time in its name and locked against changes.",
current_version: "Current version",
renamed_to: "Renamed to",
locked: "locked",
file_exists_locked: "It can't be replaced: the budget is {status}. Request reopening first.",
reopen_requested_badge: "Reopening requested",
show_archived: "Show archived versions",
hide_archived: "Hide archived versions",
no_archived: "No archived versions.",
archived_when: "Archived",
archived_badge: "Archived",
act_archive: "archived",
act_reopen_requested: "requested reopening",
act_reopen_declined: "declined reopening",
theme: 'Appearance',
theme_system: 'Automatic (from the computer)',
theme_light: 'Light',
theme_dark: 'Dark',
theme_switch: 'Switch between light, dark and automatic',
avatar: 'Picture',
avatar_upload: 'Choose picture',
avatar_remove: 'Remove',
avatar_hint: 'JPG, PNG, GIF or WebP up to 5 MB. It is cropped to a square and reduced to 256 px.',
security: 'Security',
change_password: 'Change password',
mfa: 'Two-step sign-in (MFA)',
mfa_short: 'MFA',
mfa_on: 'On',
mfa_off: 'Off',
mfa_on_since: 'On since {when}. {n} recovery codes left.',
mfa_off_text: 'Besides the password, asks for a 6-digit code from an app on your phone (Microsoft Authenticator, Google Authenticator, Authy…).',
mfa_setup: 'Turn on MFA',
mfa_disable: 'Turn off MFA',
mfa_new_codes: 'New recovery codes',
mfa_step1: '1. Open your authenticator app and scan this QR code.',
mfa_manual: 'Or type this key in the app:',
mfa_step2: '2. Type the 6-digit code the app shows.',
mfa_code: 'Code',
mfa_confirm: 'Confirm and turn on',
mfa_codes_title: 'Save your recovery codes',
mfa_codes_text: 'Each code works once, if you are ever without your phone. Keep them somewhere safe: they are not shown again.',
mfa_codes_copy: 'Copy',
mfa_codes_done: 'I have saved the codes',
mfa_disable_text: 'To turn it off, type your password and a code from the app (or a recovery code).',
mfa_new_codes_text: 'The old codes stop working. Type a code from the app to continue.',
mfa_login_title: 'Verification code',
mfa_login_text: 'Open your authenticator app and type the 6-digit code. Without your phone, use a recovery code.',
mfa_back: 'Back',
mfa_verify: 'Verify',
mfa_reset: 'Reset MFA',
mfa_reset_text: "Turns off this user's MFA (for example if they lost their phone). They can turn it on again in their profile.",
mfa_reset_done: 'MFA turned off for this user.',
act_mfa_enabled: 'turned on MFA',
act_mfa_disabled: 'turned off MFA',
act_mfa_reset: 'reset MFA',
act_mfa_failed: 'entered a wrong MFA code',
act_mfa_recovery_used: 'used a recovery code',
act_mfa_recovery_renewed: 'created new recovery codes',
act_avatar_changed: 'changed their picture',
act_avatar_removed: 'removed their picture',
errors: {
invalid_mfa_code: 'Wrong or already used code. Try the next code from the app.',
mfa_locked: 'Too many wrong codes. Wait 5 minutes and try again.',
mfa_setup_first: 'Start turning on MFA again.',
mfa_already_on: 'MFA is already on.',
mfa_off: 'MFA is not on.',
not_an_image: 'The file is not a picture.',
image_too_large: 'The picture is larger than 5 MB.',
reason_required: "Give the reason (at least 5 characters).",
reopen_already_requested: "There is already a pending reopen request.",
year_not_draft: "The budget is not a draft. Request reopening first.",
mail_not_configured: "Sending by email is not set up yet. Ask an administrator.",
mail_incomplete: "Fill in the SMTP server and the sender address.",
invalid_address: "Invalid email address.",
no_recipients: "Add at least one recipient.",
too_many_recipients: "Too many recipients (25 at most).",
no_attachments: "Choose at least one report to attach.",
smtp_auth: "The mail server rejected the username or password.",
smtp_connect: "Could not connect to the mail server. Check the server and port.",
smtp_tls: "The secure (TLS) connection to the mail server failed. Check the security option.",
smtp_recipients: "The server rejected one or more recipients.",
smtp_error: "The mail server returned an error.",
invalid_message: "The email could not be built.",
password_unreadable: "The saved password can no longer be read (SECRET_KEY changed). Enter it again.",
invalid_security: "Invalid security option.",
no_months_selected: 'Choose at least one month.',
invalid_date: "Invalid date.",
file_too_large: "The file is too large.",
invalid_department: 'Choose a valid department.',
workflow_in_use: "This department has budgets in review. Approve or return them before changing the workflow.",
step_needs_approvers: 'Choose at least one user for each step with selected users.',
invalid_step: 'Every step needs a name.',
template_header_missing: "The template's header row was not found.",
invalid_transition: 'The budget changed in the meantime. Refresh the page.',
year_exists: 'That year already exists.',
year_not_draft: 'The budget is not a draft.',
missing_fields: 'Fill in the required fields.',
not_an_xlsx: 'The file is not a valid Excel file (.xlsx).',
missing_sheets: 'The file has no "Sheet1" and "Budget IT" sheets.',
year_not_found: 'Could not detect the year. Please enter it.',
email_taken: 'That email is already registered.',
password_too_short: 'The password needs at least 8 characters.',
wrong_password: 'The current password is wrong.',
category_in_use: 'The category has lines and cannot be deleted. Deactivate it instead.',
name_taken: 'That name already exists.',
forbidden: 'You do not have permission for this.',
cannot_demote_self: 'You cannot remove your own administrator access.',
},
}
export const dictionaries = { pt, en }
export function makeT(lang, vars = {}) {
const dict = dictionaries[lang] || pt
const t = (key, params = {}) => {
const text = dict[key] ?? pt[key] ?? key
return String(text).replace(/\{(\w+)\}/g, (_, k) => (params[k] ?? vars[k] ?? `{${k}}`))
}
t.error = (code) => {
const base = String(code || '').split(':')[0]
if (base === 'invalid_expression') return t('invalid_expression')
return dict.errors[base] || t('error', { msg: code })
}
t.lang = lang
return t
}
export const I18nContext = createContext(makeT('pt'))
export const useT = () => useContext(I18nContext)
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote } from '../components/ui.jsx'
const PAGE = 100
// Filters shared by the activity log and the mail log: search, user, from/to dates.
export function useLogFilters(extra = {}) {
const [filters, setFilters] = useState({ q: '', user_id: '', date_from: '', date_to: '', ...extra })
const [offset, setOffset] = useState(0)
const set = (k) => (e) => { setFilters({ ...filters, [k]: e.target.value }); setOffset(0) }
const query = (withPage = true) => {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => v && q.set(k, v))
if (withPage) { q.set('limit', PAGE); q.set('offset', offset) }
return q.toString()
}
return { filters, set, offset, setOffset, query }
}
export function Pager({ total, offset, setOffset }) {
const t = useT()
if (total <= PAGE) return <span className="muted small">{t('results_count', { n: total })}</span>
return (
<div className="pager">
<span className="muted small">{offset + 1}–{Math.min(offset + PAGE, total)} / {total}</span>
<button className="small" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>{t('prev')}</button>
<button className="small" disabled={offset + PAGE >= total} onClick={() => setOffset(offset + PAGE)}>{t('next')}</button>
</div>
)
}
export function UserDateFilters({ f, users }) {
const t = useT()
return <>
<input type="search" placeholder={t('search_log')} value={f.filters.q} onChange={f.set('q')} />
<select value={f.filters.user_id} onChange={f.set('user_id')} aria-label={t('who')}>
<option value="">{t('all_users')}</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<label className="inline">{t('from_date')} <input type="date" value={f.filters.date_from} onChange={f.set('date_from')} /></label>
<label className="inline">{t('to_date')} <input type="date" value={f.filters.date_to} onChange={f.set('date_to')} /></label>
</>
}
const label = (t, prefix, key) => {
const text = t(`${prefix}_${key}`)
return text === `${prefix}_${key}` ? key : text
}
function describe(r) {
const changes = r.changes && typeof r.changes === 'object' && !Array.isArray(r.changes)
? Object.entries(r.changes).map(([k, v]) => Array.isArray(v) && v.length === 2 ? `${k}: ${v[0] ?? '∅'} → ${v[1] ?? '∅'}` : null).filter(Boolean)
: []
return changes
}
export default function Activity() {
const t = useT()
const { lang } = useAuth()
const f = useLogFilters({ entity: '', action: '' })
const [data, setData] = useState(null)
const [users, setUsers] = useState([])
const [open, setOpen] = useState(null)
const [error, setError] = useState(null)
const q = f.query()
useEffect(() => { api.get('/api/users').then(setUsers) }, [])
useEffect(() => { api.get(`/api/activity?${q}`).then(setData).catch(setError) }, [q])
return (
<div>
<div className="page-head">
<h1>{t('activity_title')}</h1>
<a className="button" href={`/api/activity.csv?${f.query(false)}`}>{t('export_csv')}</a>
</div>
<p className="muted small">{t('activity_hint')}</p>
<div className="toolbar wrap">
<UserDateFilters f={f} users={users} />
<select value={f.filters.entity} onChange={f.set('entity')} aria-label={t('col_area')}>
<option value="">{t('all_areas')}</option>
{(data?.entities || []).map((e) => <option key={e} value={e}>{label(t, 'area', e)}</option>)}
</select>
<select value={f.filters.action} onChange={f.set('action')} aria-label={t('col_action')}>
<option value="">{t('all_actions')}</option>
{(data?.actions || []).map((a) => <option key={a} value={a}>{label(t, 'act', a)}</option>)}
</select>
</div>
<ErrorNote error={error} />
{data && (
<div className="card">
<div className="card-head"><Pager total={data.total} offset={f.offset} setOffset={f.setOffset} /></div>
{data.rows.length === 0 ? <p className="muted">{t('nothing_yet')}</p> : (
<table className="plain log">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('col_area')}</th><th>{t('col_action')}</th><th>{t('col_budget')}</th><th>{t('details')}</th></tr></thead>
<tbody>{data.rows.map((r) => {
const changes = describe(r)
return (
<tr key={r.id} className={r.action.endsWith('failed') ? 'bad' : ''}>
<td className="nowrap">{dateTime(r.at, lang)}</td>
<td>{r.user || <span className="muted">—</span>}</td>
<td>{label(t, 'area', r.entity)}</td>
<td>{label(t, 'act', r.action)}</td>
<td className="nowrap">{r.budget || ''}{r.year_id && !r.budget_exists && <span className="muted small"> ({t('deleted')})</span>}</td>
<td className="small">
{r.summary}
{changes.length > 0 && (open === r.id
? <ul className="changes">{changes.map((c, i) => <li key={i}>{c}</li>)}</ul>
: <button className="link small" onClick={() => setOpen(r.id)}>+{changes.length}</button>)}
</td>
</tr>
)
})}</tbody>
</table>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Departments() {
const t = useT()
const [depts, setDepts] = useState([])
const [users, setUsers] = useState([])
const [creating, setCreating] = useState(false)
const [error, setError] = useState(null)
const load = useCallback(() => {
api.get('/api/departments').then(setDepts)
api.get('/api/users').then(setUsers)
}, [])
useEffect(() => { load() }, [load])
return (
<div>
<div className="page-head">
<h1>{t('departments')}</h1>
<button className="primary" onClick={() => setCreating(true)}>{t('new_department')}</button>
</div>
<p className="muted small">{t('workflow_hint')}</p>
<ErrorNote error={error} />
{depts.map((d) => (
<DepartmentCard key={d.id} dept={d} users={users} onChanged={load} onError={setError} />
))}
{creating && <NewDepartment depts={depts} onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load() }} />}
</div>
)
}
function DepartmentCard({ dept, users, onChanged, onError }) {
const t = useT()
const [name, setName] = useState(dept.name)
const [steps, setSteps] = useState(dept.steps)
const [saved, setSaved] = useState(false)
useEffect(() => { setName(dept.name); setSteps(dept.steps) }, [dept])
const members = users.filter((u) => u.department_id === dept.id && u.active)
const dirty = JSON.stringify(steps) !== JSON.stringify(dept.steps)
const rename = async () => {
if (!name.trim() || name === dept.name) return
try { await api.put(`/api/departments/${dept.id}`, { name }); onChanged() } catch (e) { onError(e) }
}
const toggle = async () => {
try { await api.put(`/api/departments/${dept.id}`, { active: !dept.active }); onChanged() } catch (e) { onError(e) }
}
const update = (i, patch) => setSteps(steps.map((s, k) => (k === i ? { ...s, ...patch } : s)))
const move = (i, dir) => {
const next = [...steps]
const [s] = next.splice(i, 1)
next.splice(i + dir, 0, s)
setSteps(next)
}
const save = async () => {
try {
await api.put(`/api/departments/${dept.id}/workflow`, {
steps: steps.map((s) => ({ name: s.name, approver_mode: s.approver_mode, approver_ids: s.approver_ids || [] })),
})
onError(null)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
onChanged()
} catch (e) { onError(e) }
}
return (
<section className={'card dept' + (dept.active ? '' : ' muted')}>
<div className="cat-head">
<h2 className="dept-name"><input className="cell title" value={name} onChange={(e) => setName(e.target.value)} onBlur={rename}
onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} aria-label={t('name')} /></h2>
<span className="muted small">{members.length} {t('nav_users').toLowerCase()}</span>
<label className="check"><input type="checkbox" checked={dept.active} onChange={toggle} /> {t('active')}</label>
</div>
<div className="dept-body">
<h3>{t('workflow')}</h3>
{steps.length === 0 && <p className="muted small">{t('workflow_default')}</p>}
<ol className="steps">
{steps.map((s, i) => (
<li key={i} className="step">
<div className="step-row">
<span className="step-num">{i + 1}</span>
<input value={s.name} placeholder={t('step_name')} onChange={(e) => update(i, { name: e.target.value })} aria-label={t('step_name')} />
<select value={s.approver_mode} onChange={(e) => update(i, { approver_mode: e.target.value })} aria-label={t('approver_mode')}>
<option value="role">{t('mode_role')}</option>
<option value="users">{t('mode_users')}</option>
</select>
<button className="icon" disabled={i === 0} onClick={() => move(i, -1)} title={t('move_up')} aria-label={t('move_up')}>↑</button>
<button className="icon" disabled={i === steps.length - 1} onClick={() => move(i, 1)} title={t('move_down')} aria-label={t('move_down')}>↓</button>
<button className="icon danger" onClick={() => setSteps(steps.filter((_, k) => k !== i))} title={t('delete')} aria-label={t('delete')}>×</button>
</div>
{s.approver_mode === 'users' && (
<div className="user-picks">
{users.filter((u) => u.active && u.role !== 'management').map((u) => (
<label key={u.id} className="check pick">
<input type="checkbox" checked={(s.approver_ids || []).includes(u.id)}
onChange={(e) => update(i, { approver_ids: e.target.checked ? [...(s.approver_ids || []), u.id] : (s.approver_ids || []).filter((x) => x !== u.id) })} />
{u.name} <span className="muted small">{u.department || t('no_department')}</span>
</label>
))}
</div>
)}
</li>
))}
</ol>
<div className="actions">
<button onClick={() => setSteps([...steps, { name: '', approver_mode: 'role', approver_ids: [] }])}>+ {t('add_step')}</button>
<button className="primary" disabled={!dirty} onClick={save}>{t('save_workflow')}</button>
{saved && <span className="muted">{t('saved')}</span>}
</div>
</div>
</section>
)
}
function NewDepartment({ depts, onClose, onSaved }) {
const t = useT()
const [name, setName] = useState('')
const [source, setSource] = useState('defaults')
const [error, setError] = useState(null)
const submit = async () => {
try {
await api.post('/api/departments', { name, categories_from: source })
onSaved()
} catch (e) { setError(e) }
}
return (
<Modal title={t('new_department')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('create')}</button></>}>
<Field label={t('name')}><input value={name} onChange={(e) => setName(e.target.value)} autoFocus /></Field>
<Field label={t('categories_from')}>
<select value={source} onChange={(e) => setSource(e.target.value)}>
<option value="defaults">{t('categories_defaults')}</option>
<option value="none">{t('categories_none')}</option>
{depts.map((d) => <option key={d.id} value={d.id}>{t('categories_copy', { name: d.name })}</option>)}
</select>
</Field>
<ErrorNote error={error} />
</Modal>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ThemeButton } from '../components/ui.jsx'
export default function Login({ onLogin }) {
const t = useT()
const { lang, setLang } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
// with MFA on, a second step asks for the authenticator (or recovery) code
const [mfa, setMfa] = useState(false)
const [code, setCode] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await api.post('/api/auth/login', mfa ? { email, password, code } : { email, password })
if (res.mfa_required) setMfa(true)
else onLogin(res)
} catch (err) {
setError(mfa ? t.error(err.code) : t('invalid_credentials'))
if (mfa) setCode('')
} finally {
setBusy(false)
}
}
const back = () => { setMfa(false); setCode(''); setError(null) }
return (
<div className="login-wrap">
<form className="card login" onSubmit={submit}>
<div className="brand big"><span className="logo" aria-hidden="true"><i /><i /><i /></span>{t('app_name')}</div>
{!mfa ? <>
<h1>{t('login_title')}</h1>
<label className="field"><span className="field-label">{t('email')}</span>
<input type="email" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
</label>
<label className="field"><span className="field-label">{t('password')}</span>
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
</> : <>
<h1>{t('mfa_login_title')}</h1>
<p className="muted small">{t('mfa_login_text')}</p>
<label className="field"><span className="field-label">{t('mfa_code')}</span>
<input className="code" inputMode="text" autoComplete="one-time-code" maxLength={12} value={code}
onChange={(e) => setCode(e.target.value)} required autoFocus />
</label>
</>}
{error && <div className="error" role="alert">{error}</div>}
<button className="primary" disabled={busy}>{mfa ? t('mfa_verify') : t('sign_in')}</button>
{mfa && <button type="button" className="link" onClick={back}>{t('mfa_back')}</button>}
<div className="login-tools">
<div className="lang">
{['pt', 'en'].map((l) => (
<button type="button" key={l} className={l === lang ? 'on' : ''} onClick={() => setLang(l)}>{l.toUpperCase()}</button>
))}
</div>
<ThemeButton />
</div>
</form>
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { Fragment, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field } from '../components/ui.jsx'
import { Pager, UserDateFilters, useLogFilters } from './Activity.jsx'
export default function Mail() {
const t = useT()
const [params, setParams] = useSearchParams()
const tab = params.get('tab') === 'log' ? 'log' : 'account'
return (
<div>
<div className="page-head"><h1>{t('mail_title')}</h1></div>
<div className="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'account'} className={tab === 'account' ? 'on' : ''} onClick={() => setParams({})}>{t('mail_tab_account')}</button>
<button role="tab" aria-selected={tab === 'log'} className={tab === 'log' ? 'on' : ''} onClick={() => setParams({ tab: 'log' })}>{t('mail_tab_log')}</button>
</div>
{tab === 'account' ? <Account /> : <MailLog />}
</div>
)
}
function Account() {
const t = useT()
const { user, lang } = useAuth()
const [saved, setSaved] = useState(null)
const [form, setForm] = useState(null)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [check, setCheck] = useState(null)
const [testTo, setTestTo] = useState(user.email)
const [busy, setBusy] = useState(false)
const take = (s) => { setSaved(s); setForm({ ...s, smtp_password: '', imap_password: '' }); setDirty(false) }
useEffect(() => { api.get('/api/mail/settings').then(take).catch(setError) }, [])
if (!form) return <ErrorNote error={error} />
const set = (k, numeric) => (e) => {
const v = e.target.type === 'checkbox' ? e.target.checked : numeric ? Number(e.target.value) : e.target.value
const next = { ...form, [k]: v }
// moving between the usual ports follows the security choice
if (k === 'smtp_security') next.smtp_port = { starttls: 587, ssl: 465, none: 25 }[v]
if (k === 'imap_security') next.imap_port = { starttls: 143, ssl: 993, none: 143 }[v]
setForm(next)
setDirty(true)
}
const run = async (fn) => {
setBusy(true); setError(null); setNotice(null)
try { await fn() } catch (e) { setError(e) } finally { setBusy(false) }
}
const save = () => run(async () => {
const body = { ...form }
for (const k of ['ready', 'updated_at', 'updated_by', 'smtp_password_set', 'imap_password_set']) delete body[k]
take(await api.put('/api/mail/settings', body))
setNotice(t('mail_saved'))
})
const test = (to) => run(async () => {
setCheck(null)
const r = await api.post('/api/mail/test', to ? { to } : {})
setCheck(to ? { mail: r } : r)
})
const security = (k) => (
<select value={form[k]} onChange={set(k)}>
{['starttls', 'ssl', 'none'].map((s) => <option key={s} value={s}>{t(`security_${s}`)}</option>)}
</select>
)
const result = (text) => (text === 'ok' ? t('ok') : t.error(text))
return (
<div className="mail-form">
<ErrorNote error={error} />
{notice && <div className="notice">{notice}</div>}
<section className="card">
<label className="check big"><input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> {t('mail_enabled')}</label>
<p className="hint">{t('mail_enabled_hint')}</p>
{saved.updated_by && <p className="hint">{t('last_changed', { name: saved.updated_by, when: dateTime(saved.updated_at, lang) })}</p>}
</section>
<section className="card">
<h2>{t('mail_smtp')}</h2>
<div className="grid3">
<Field label={t('host')}><input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" /></Field>
<Field label={t('security')}>{security('smtp_security')}</Field>
<Field label={t('port')}><input type="number" value={form.smtp_port} onChange={set('smtp_port', true)} /></Field>
<Field label={t('username')}><input value={form.smtp_username} onChange={set('smtp_username')} autoComplete="off" /></Field>
<Field label={t('password') + (saved.smtp_password_set ? ` (${t('password_saved')})` : '')} hint={saved.smtp_password_set ? t('password_keep_hint') : null}>
<input type="password" value={form.smtp_password} onChange={set('smtp_password')} autoComplete="new-password" />
</Field>
</div>
</section>
<section className="card">
<h2>{t('mail_sender')}</h2>
<div className="grid3">
<Field label={t('from_address')}><input type="email" value={form.from_address} onChange={set('from_address')} placeholder="orcamentos@empresa.pt" /></Field>
<Field label={t('from_name')}><input value={form.from_name} onChange={set('from_name')} /></Field>
<Field label={t('reply_to')}><input type="email" value={form.reply_to} onChange={set('reply_to')} /></Field>
</div>
<Field label={t('signature')}><textarea rows={3} value={form.signature} onChange={set('signature')} /></Field>
</section>
<section className="card">
<h2>{t('mail_sent_copy')}</h2>
<label className="check"><input type="checkbox" checked={form.save_to_sent} onChange={set('save_to_sent')} /> {t('save_to_sent')}</label>
<p className="hint">{t('save_to_sent_hint')}</p>
{form.save_to_sent && <>
<div className="grid3">
<Field label={t('host')} hint={t('imap_host_hint')}><input value={form.imap_host} onChange={set('imap_host')} placeholder={form.smtp_host || 'imap.example.com'} /></Field>
<Field label={t('security')}>{security('imap_security')}</Field>
<Field label={t('port')}><input type="number" value={form.imap_port} onChange={set('imap_port', true)} /></Field>
</div>
<label className="check"><input type="checkbox" checked={form.imap_same_login} onChange={set('imap_same_login')} /> {t('imap_same_login')}</label>
{!form.imap_same_login && <div className="grid3">
<Field label={t('username')}><input value={form.imap_username} onChange={set('imap_username')} autoComplete="off" /></Field>
<Field label={t('password') + (saved.imap_password_set ? ` (${t('password_saved')})` : '')} hint={saved.imap_password_set ? t('password_keep_hint') : null}>
<input type="password" value={form.imap_password} onChange={set('imap_password')} autoComplete="new-password" />
</Field>
</div>}
<div className="grid3">
<Field label={t('sent_folder')} hint={t('sent_folder_hint')}><input value={form.sent_folder} onChange={set('sent_folder')} /></Field>
</div>
</>}
</section>
<div className="actions sticky-foot">
<button className="primary" disabled={busy || !dirty} onClick={save}>{t('save')}</button>
<span className="spacer" />
<button disabled={busy || dirty || !saved.smtp_host} title={dirty ? t('mail_save_first') : ''} onClick={() => test(null)}>{t('mail_check')}</button>
<input type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} aria-label={t('mail_test_to')} />
<button disabled={busy || dirty || !saved.smtp_host || !testTo} title={dirty ? t('mail_save_first') : ''} onClick={() => test(testTo)}>{t('mail_test_send')}</button>
</div>
{busy && <p className="muted small">{t('email_sending')}</p>}
{check && !check.mail && (
<div className="notice">
<div>{t('mail_check_smtp', { result: result(check.smtp) })}</div>
{check.imap !== 'off' && <div>{t('mail_check_imap', { result: result(check.imap) })}</div>}
{check.sent_folder && <div>{t('mail_check_folder', { folder: check.sent_folder })}</div>}
</div>
)}
{check?.mail && <SendResult log={check.mail} />}
</div>
)
}
export function SendResult({ log }) {
const t = useT()
if (log.status !== 'sent') return <div className="error">{t.error(log.error)} <span className="small">{log.error}</span></div>
return (
<div className="notice">
<div>{t('email_sent', { to: [...log.to, ...log.cc].join(', ') })}</div>
{log.sent_copy === 'saved' && <div className="muted small">{t('email_copy_saved', { folder: log.sent_copy_detail })}</div>}
{log.sent_copy === 'failed' && <div className="error small">{t('email_copy_failed', { detail: log.sent_copy_detail })}</div>}
</div>
)
}
function MailLog() {
const t = useT()
const { lang } = useAuth()
const f = useLogFilters({ status: '' })
const [data, setData] = useState(null)
const [users, setUsers] = useState([])
const [open, setOpen] = useState(null)
const [error, setError] = useState(null)
const q = f.query()
useEffect(() => { api.get('/api/users').then(setUsers) }, [])
useEffect(() => { api.get(`/api/mail/log?${q}`).then(setData).catch(setError) }, [q])
const kb = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB')
return (
<div>
<p className="muted small">{t('mail_log_hint')}</p>
<div className="toolbar wrap">
<UserDateFilters f={f} users={users} />
<select value={f.filters.status} onChange={f.set('status')} aria-label={t('col_status')}>
<option value="">{t('all_statuses')}</option>
<option value="sent">{t('log_sent')}</option>
<option value="failed">{t('log_failed')}</option>
</select>
<a className="button" href={`/api/mail/log.csv?${f.query(false)}`}>{t('export_csv')}</a>
</div>
<ErrorNote error={error} />
{data && (
<div className="card">
<div className="card-head"><Pager total={data.total} offset={f.offset} setOffset={f.setOffset} /></div>
{data.rows.length === 0 ? <p className="muted">{t('nothing_yet')}</p> : (
<table className="plain log">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('col_to')}</th><th>{t('col_subject')}</th><th>{t('col_attachments')}</th><th>{t('col_status')}</th><th>{t('col_copy')}</th></tr></thead>
<tbody>{data.rows.map((m) => (<Fragment key={m.id}>
<tr className={'clickable' + (m.status === 'failed' ? ' bad' : '')} onClick={() => setOpen(open === m.id ? null : m.id)}>
<td className="nowrap">{dateTime(m.at, lang)}</td>
<td>{m.user}</td>
<td className="small">{m.to.join(', ')}{m.cc.length > 0 && <div className="muted">cc: {m.cc.join(', ')}</div>}</td>
<td>{m.subject}{m.kind === 'test' && <span className="badge"> {t('test_email')}</span>}{m.context && m.kind !== 'test' && <div className="muted small">{m.context}</div>}</td>
<td className="small">{m.attachments.map((a) => <div key={a.name} className="nowrap">{a.name} <span className="muted">{kb(a.size)}</span></div>)}</td>
<td><span className={'badge ' + (m.status === 'sent' ? 'st-approved' : 'st-failed')}>{t(`log_${m.status}`)}</span></td>
<td><span className={'badge ' + (m.sent_copy === 'saved' ? 'st-approved' : m.sent_copy === 'failed' ? 'st-failed' : '')}>{t(`copy_${m.sent_copy}`)}</span></td>
</tr>
{open === m.id && (
<tr className="detail"><td colSpan={7}>
<dl className="kv wide">
<dt>{t('col_from')}</dt><dd>{m.sender}{m.user_email && <span className="muted"> · {m.user} &lt;{m.user_email}&gt;</span>}</dd>
{m.error && <><dt>{t('col_status')}</dt><dd className="bad-text">{t.error(m.error)} <span className="muted small">{m.error}</span></dd></>}
{m.sent_copy !== 'off' && <><dt>{t('col_copy')}</dt><dd>{m.sent_copy_detail}</dd></>}
<dt>Message-ID</dt><dd className="small muted">{m.message_id}</dd>
</dl>
{m.body && <pre className="mail-body">{m.body}</pre>}
</td></tr>
)}
</Fragment>))}</tbody>
</table>
)}
</div>
)}
</div>
)
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
import { THEMES, useAuth } from '../auth.js'
import { dateTime } from '../format.js'
import { useT } from '../i18n.js'
import { Avatar, ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Profile() {
const t = useT()
const { user, setUser, setLang, theme, setTheme } = useAuth()
const [name, setName] = useState(user.name)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
const submit = async (e) => {
e.preventDefault()
try {
const body = { name }
if (next) Object.assign(body, { current_password: current, new_password: next })
setUser(await api.put('/api/auth/me', body))
setCurrent('')
setNext('')
setError(null)
setDone(true)
} catch (err) { setError(err); setDone(false) }
}
return (
<div className="card narrow">
<h1>{t('profile_title')}</h1>
<AvatarPicker />
<form onSubmit={submit}>
<Field label={t('name')}><input value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label={t('email')}><input value={user.email} disabled /></Field>
<Field label={t('role')}><input value={t(`role_${user.role}`)} disabled /></Field>
<Field label={t('language')}>
<select value={user.language} onChange={(e) => { setLang(e.target.value); setUser({ ...user, language: e.target.value }) }}>
<option value="pt">Português</option><option value="en">English</option>
</select>
</Field>
<Field label={t('theme')}>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
{THEMES.map((th) => <option key={th} value={th}>{t(`theme_${th}`)}</option>)}
</select>
</Field>
<h2 className="section-title">{t('change_password')}</h2>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} /></Field>
<Field label={t('new_password')} hint={t('password_optional')}><input type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} /></Field>
<ErrorNote error={error} />
{done && <div className="notice">{t('saved')}</div>}
<button className="primary">{t('save')}</button>
</form>
<MfaSection />
</div>
)
}
function AvatarPicker() {
const t = useT()
const { user, setUser } = useAuth()
const input = useRef(null)
const [error, setError] = useState(null)
const upload = async (e) => {
const file = e.target.files[0]
e.target.value = ''
if (!file) return
const form = new FormData()
form.append('file', file)
try { setUser(await api.put('/api/auth/me/avatar', form)); setError(null) } catch (err) { setError(err) }
}
const remove = async () => {
try { setUser(await api.del('/api/auth/me/avatar')); setError(null) } catch (err) { setError(err) }
}
return (
<div>
<div className="avatar-row">
<Avatar user={user} large />
<div>
<div className="actions">
<button type="button" onClick={() => input.current.click()}>{t('avatar_upload')}</button>
{user.avatar_url && <button type="button" className="link" onClick={remove}>{t('avatar_remove')}</button>}
</div>
<div className="hint small muted" style={{ marginTop: 6 }}>{t('avatar_hint')}</div>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden onChange={upload} />
</div>
<ErrorNote error={error} />
</div>
)
}
function MfaSection() {
const t = useT()
const { user, lang } = useAuth()
const [dialog, setDialog] = useState(null) // setup | disable | codes
return (
<div>
<h2 className="section-title">{t('mfa')} <span className={'badge' + (user.mfa_enabled ? ' mfa-on' : '')}>{user.mfa_enabled ? t('mfa_on') : t('mfa_off')}</span></h2>
{user.mfa_enabled ? <>
<p className="muted small">{t('mfa_on_since', { when: dateTime(user.mfa_enabled_at, lang), n: user.mfa_recovery_left })}</p>
<div className="actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" onClick={() => setDialog('codes')}>{t('mfa_new_codes')}</button>
<button type="button" className="danger" onClick={() => setDialog('disable')}>{t('mfa_disable')}</button>
</div>
</> : <>
<p className="muted small">{t('mfa_off_text')}</p>
<button type="button" className="primary" onClick={() => setDialog('setup')}>{t('mfa_setup')}</button>
</>}
{dialog === 'setup' && <MfaSetup onClose={() => setDialog(null)} />}
{dialog === 'disable' && <MfaDisable onClose={() => setDialog(null)} />}
{dialog === 'codes' && <MfaNewCodes onClose={() => setDialog(null)} />}
</div>
)
}
function RecoveryCodes({ codes, onDone }) {
const t = useT()
const [copied, setCopied] = useState(false)
const copy = () => navigator.clipboard?.writeText(codes.join('\n')).then(() => setCopied(true)).catch(() => {})
return <>
<p>{t('mfa_codes_text')}</p>
<div className="codes">{codes.map((c) => <span key={c}>{c}</span>)}</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={copy}>{copied ? '✓ ' : ''}{t('mfa_codes_copy')}</button>
<button type="button" className="primary" onClick={onDone}>{t('mfa_codes_done')}</button>
</div>
</>
}
function MfaSetup({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [setup, setSetup] = useState(null)
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { api.post('/api/auth/mfa/setup').then(setSetup).catch(setError) }, [])
const confirm = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/enable', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
if (codes) return <Modal title={t('mfa_codes_title')} onClose={onClose}><RecoveryCodes codes={codes} onDone={onClose} /></Modal>
return (
<Modal title={t('mfa_setup')} onClose={onClose}>
{setup && <form onSubmit={confirm}>
<p>{t('mfa_step1')}</p>
<div className="qr" dangerouslySetInnerHTML={{ __html: setup.qr_svg }} />
<p className="muted small">{t('mfa_manual')}</p>
<code className="secret">{setup.secret.match(/.{1,4}/g).join(' ')}</code>
<p>{t('mfa_step2')}</p>
<Field label={t('mfa_code')}>
<input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_confirm')}</button>
</div>
</form>}
{!setup && <ErrorNote error={error} />}
</Modal>
)
}
function MfaDisable({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try { setUser(await api.post('/api/auth/mfa/disable', { password, code })); onClose() } catch (err) { setError(err) }
}
return (
<Modal title={t('mfa_disable')} onClose={onClose}>
<form onSubmit={submit}>
<p>{t('mfa_disable_text')}</p>
<Field label={t('current_password')}><input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required /></Field>
<Field label={t('mfa_code')}><input className="code" autoComplete="one-time-code" maxLength={12} value={code} onChange={(e) => setCode(e.target.value)} required /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_disable')}</button>
</div>
</form>
</Modal>
)
}
function MfaNewCodes({ onClose }) {
const t = useT()
const { setUser } = useAuth()
const [code, setCode] = useState('')
const [codes, setCodes] = useState(null)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
try {
const r = await api.post('/api/auth/mfa/recovery-codes', { code })
setCodes(r.recovery_codes)
setUser(r.user)
} catch (err) { setError(err); setCode('') }
}
return (
<Modal title={codes ? t('mfa_codes_title') : t('mfa_new_codes')} onClose={onClose}>
{codes ? <RecoveryCodes codes={codes} onDone={onClose} /> : (
<form onSubmit={submit}>
<p>{t('mfa_new_codes_text')}</p>
<Field label={t('mfa_code')}><input className="code" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} required autoFocus /></Field>
<ErrorNote error={error} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={onClose}>{t('cancel')}</button>
<button className="primary">{t('mfa_new_codes')}</button>
</div>
</form>
)}
</Modal>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { StatusBadge } from '../components/ui.jsx'
import EmailDialog from '../components/EmailDialog.jsx'
import { Link } from 'react-router-dom'
export default function Reports() {
const t = useT()
const { user, lang } = useAuth()
const [params, setParams] = useSearchParams()
const [years, setYears] = useState([])
const [sites, setSites] = useState([])
const [reportLang, setReportLang] = useState(lang)
const [siteId, setSiteId] = useState('')
const [includeEmpty, setIncludeEmpty] = useState(false)
const yearId = params.get('year') || years[0]?.id
const viewing = params.get('view')
const [mail, setMail] = useState({ enabled: false })
const [emailing, setEmailing] = useState(null)
useEffect(() => {
api.get('/api/years').then(setYears)
api.get('/api/sites').then(setSites)
api.get('/api/mail/status').then(setMail).catch(() => {})
}, [])
const year = years.find((y) => String(y.id) === String(yearId))
const url = (kind, fmt) => {
const q = new URLSearchParams({ lang: reportLang })
if (siteId) q.set('site_id', siteId)
if (includeEmpty && kind === 'budget') q.set('include_empty', 'true')
return `/api/years/${yearId}/reports/${kind}.${fmt}?${q}`
}
const setYear = (id) => setParams({ year: id })
const view = (kind) => setParams({ year: yearId, view: kind })
const email = mail.enabled ? (kind) => setEmailing(kind) : null
if (!years.length) return <div><h1>{t('reports_title')}</h1><p className="muted">{t('no_years')}</p></div>
return (
<div>
<div className="page-head">
<h1>{t('reports_title')}</h1>
</div>
<div className="toolbar">
<select value={yearId} onChange={(e) => setYear(e.target.value)} aria-label={t('year')}>
{years.map((y) => <option key={y.id} value={y.id}>{y.department} · {y.year} · {y.title}</option>)}
</select>
{year && <StatusBadge status={year.status} />}
<select value={reportLang} onChange={(e) => setReportLang(e.target.value)} aria-label={t('language')}>
<option value="pt">Português</option>
<option value="en">English</option>
</select>
<select value={siteId} onChange={(e) => setSiteId(e.target.value)} aria-label={t('site')}>
<option value="">{t('all_sites')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{!mail.enabled && atLeast(user, 'admin') && (
<div className="notice muted small">{t('email_not_configured')} <Link to="/mail">{t('email_configure')}</Link></div>
)}
<div className="report-grid">
<ReportCard title={t('report_cost')} desc={t('report_cost_desc')} onView={() => view('cost-report')} onEmail={email && (() => email('cost-report'))}
pdf={url('cost-report', 'pdf')} xlsx={url('cost-report', 'xlsx')} />
<ReportCard title={t('report_budget')} desc={t('report_budget_desc')} onView={() => view('budget')} onEmail={email && (() => email('budget'))}
pdf={url('budget', 'pdf')} xlsx={url('budget', 'xlsx')}
extra={<label className="check"><input type="checkbox" checked={includeEmpty} onChange={(e) => setIncludeEmpty(e.target.checked)} /> {t('include_empty')}</label>} />
</div>
{viewing && (
<div className="viewer card">
<div className="viewer-head">
<strong>{viewing === 'budget' ? t('report_budget') : t('report_cost')}</strong>
<div className="actions">
<a className="button" href={url(viewing, 'pdf')}>{t('download_pdf')}</a>
<a className="button" href={url(viewing, 'xlsx')}>{t('download_xlsx')}</a>
{email && <button onClick={() => email(viewing)}>{t('send_email')}</button>}
<a className="button" href={url(viewing, 'html')} target="_blank" rel="noreferrer">↗</a>
<button onClick={() => setParams({ year: yearId })}>{t('close')}</button>
</div>
</div>
<iframe title="report" src={url(viewing, 'html')} />
</div>
)}
{emailing && year && (
<EmailDialog year={year} initial={[emailing, 'pdf']} reportLang={reportLang} siteId={siteId}
siteName={sites.find((s) => String(s.id) === String(siteId))?.name} includeEmpty={includeEmpty}
onClose={() => setEmailing(null)} />
)}
</div>
)
}
function ReportCard({ title, desc, onView, onEmail, pdf, xlsx, extra }) {
const t = useT()
return (
<div className="card report">
<h2>{title}</h2>
<p className="muted">{desc}</p>
{extra}
<div className="actions">
<button className="primary" onClick={onView}>{t('view_online')}</button>
<a className="button" href={pdf}>{t('download_pdf')}</a>
<a className="button" href={xlsx}>{t('download_xlsx')}</a>
{onEmail && <button onClick={onEmail}>{t('send_email')}</button>}
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { ErrorNote } from '../components/ui.jsx'
export default function Settings() {
const t = useT()
const { user } = useAuth()
const admin = atLeast(user, 'admin')
const [cats, setCats] = useState([])
const [sites, setSites] = useState([])
const [depts, setDepts] = useState([])
const [deptId, setDeptId] = useState(user.department_id || '')
const [copySource, setCopySource] = useState('defaults')
const [notice, setNotice] = useState(null)
const [error, setError] = useState(null)
const [newCat, setNewCat] = useState('')
const [newSite, setNewSite] = useState('')
useEffect(() => {
api.get('/api/departments').then((ds) => {
setDepts(ds)
setDeptId((cur) => cur || ds[0]?.id || '')
})
}, [])
const load = useCallback(() => {
if (deptId) api.get(`/api/categories?department_id=${deptId}`).then(setCats)
api.get('/api/sites').then(setSites)
}, [deptId])
useEffect(() => { load() }, [load])
const editableDepts = admin ? depts : depts.filter((d) => d.id === user.department_id)
const run = async (fn) => {
try {
await fn()
setError(null)
} catch (e) { setError(e) }
load()
}
const putCat = (c, patch) => run(() => api.put(`/api/categories/${c.id}`, patch))
const putSite = (s, patch) => run(() => api.put(`/api/sites/${s.id}`, patch))
return (
<div>
<h1>{admin ? t('nav_settings') : t('nav_categories')}</h1>
<ErrorNote error={error} />
{notice && <div className="notice">{notice}</div>}
{admin && <section className="card">
<h2>{t('sites')}</h2>
<table className="plain">
<tbody>
{sites.map((s) => (
<tr key={s.id}>
<td><Inline value={s.name} onSave={(v) => putSite(s, { name: v })} /></td>
<td><label className="check"><input type="checkbox" checked={s.active} onChange={(e) => putSite(s, { active: e.target.checked })} /> {t('active')}</label></td>
</tr>
))}
</tbody>
</table>
<form className="inline-add" onSubmit={(e) => { e.preventDefault(); newSite.trim() && run(() => api.post('/api/sites', { name: newSite })).then(() => setNewSite('')) }}>
<input placeholder={t('new_site')} value={newSite} onChange={(e) => setNewSite(e.target.value)} />
<button>{t('create')}</button>
</form>
</section>}
<section className="card">
<div className="cat-head flush">
<h2>{t('categories')}</h2>
<select value={deptId} onChange={(e) => setDeptId(Number(e.target.value))} aria-label={t('department')} className="auto">
{editableDepts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<select value={copySource} onChange={(e) => setCopySource(e.target.value)} aria-label={t('copy_categories')} className="auto">
<option value="defaults">{t('categories_defaults')}</option>
{depts.filter((d) => d.id !== deptId).map((d) => <option key={d.id} value={d.id}>{t('categories_copy', { name: d.name })}</option>)}
</select>
<button onClick={() => run(() => api.post(`/api/departments/${deptId}/categories/copy`, { source: copySource })).then(() => setNotice(t('copy_categories_done')))}>{t('copy_categories')}</button>
</div>
<div className="table-wrap">
<table className="plain">
<thead><tr><th>{t('order')}</th><th>{t('name_en')}</th><th>{t('name_pt')}</th><th>{t('section')}</th><th>{t('active')}</th><th /></tr></thead>
<tbody>
{cats.map((c) => (
<tr key={c.id} className={c.active ? '' : 'muted'}>
<td><Inline value={String(c.sort_order)} short onSave={(v) => putCat(c, { sort_order: Number(v) || 0 })} /></td>
<td><Inline value={c.name_en} onSave={(v) => putCat(c, { name_en: v })} /></td>
<td><Inline value={c.name_pt} onSave={(v) => putCat(c, { name_pt: v })} /></td>
<td>
<select className="cell" value={c.section} onChange={(e) => putCat(c, { section: e.target.value })}>
<option value="cost">{t('section_cost')}</option><option value="revenue">{t('section_revenue')}</option>
</select>
</td>
<td><input type="checkbox" checked={c.active} onChange={(e) => putCat(c, { active: e.target.checked })} aria-label={t('active')} /></td>
<td className="right"><button className="icon danger" aria-label={t('delete')} title={t('delete')}
onClick={() => window.confirm(t('confirm_delete', { name: c.name_en })) && run(() => api.del(`/api/categories/${c.id}`))}>×</button></td>
</tr>
))}
</tbody>
</table>
</div>
<form className="inline-add" onSubmit={(e) => { e.preventDefault(); newCat.trim() && run(() => api.post('/api/categories', { name_en: newCat, department_id: deptId })).then(() => setNewCat('')) }}>
<input placeholder={t('new_category')} value={newCat} onChange={(e) => setNewCat(e.target.value)} />
<button>{t('create')}</button>
</form>
</section>
</div>
)
}
function Inline({ value, onSave, short }) {
const [text, setText] = useState(value)
useEffect(() => setText(value), [value])
return <input className={'cell' + (short ? ' short' : '')} value={text} onChange={(e) => setText(e.target.value)}
onBlur={() => text !== value && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api.js'
import { ROLES, useAuth } from '../auth.js'
import { useT } from '../i18n.js'
import { Avatar, ErrorNote, Field, Modal } from '../components/ui.jsx'
export default function Users() {
const t = useT()
const [users, setUsers] = useState([])
const [editing, setEditing] = useState(null)
const [depts, setDepts] = useState([])
const load = useCallback(() => api.get('/api/users').then(setUsers), [])
useEffect(() => { load(); api.get('/api/departments').then(setDepts) }, [load])
return (
<div>
<div className="page-head">
<h1>{t('users_title')}</h1>
<button className="primary" onClick={() => setEditing({})}>{t('new_user')}</button>
</div>
<p className="muted small">{t('roles_hint')}</p>
<div className="card">
<table className="plain">
<thead><tr><th>{t('name')}</th><th>{t('email')}</th><th>{t('department')}</th><th>{t('role')}</th><th>{t('mfa_short')}</th><th>{t('language')}</th><th /><th /></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.active ? '' : 'muted'}>
<td><span className="namecell"><Avatar user={u} />{u.name}</span></td><td>{u.email}</td><td>{u.department || <span className="muted">{t('no_department')}</span>}</td><td>{t(`role_${u.role}`)}</td>
<td><span className={'badge' + (u.mfa_enabled ? ' mfa-on' : '')}>{u.mfa_enabled ? t('mfa_on') : t('mfa_off')}</span></td>
<td>{u.language.toUpperCase()}</td>
<td>{u.active ? t('active') : t('inactive')}</td>
<td className="right"><button className="small" onClick={() => setEditing(u)}>{t('edit')}</button></td>
</tr>
))}
</tbody>
</table>
</div>
{editing && <UserDialog user={editing} depts={depts} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
</div>
)
}
function UserDialog({ user, depts, onClose, onSaved }) {
const t = useT()
const { user: me } = useAuth()
const isNew = !user.id
const [form, setForm] = useState({
name: user.name || '', email: user.email || '', role: user.role || 'editor',
language: user.language || 'pt', active: user.active ?? true, password: '',
department_id: user.id ? (user.department_id ?? '') : (depts[0]?.id ?? ''),
})
const [error, setError] = useState(null)
const [mfaReset, setMfaReset] = useState(false)
const resetMfa = async () => {
try { await api.post(`/api/users/${user.id}/mfa/reset`); setMfaReset(true) } catch (e) { setError(e) }
}
const set = (k) => (e) => setForm({ ...form, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })
const submit = async () => {
const body = { ...form, department_id: form.department_id ? Number(form.department_id) : null }
if (!body.password) delete body.password
try {
if (isNew) await api.post('/api/users', body)
else await api.put(`/api/users/${user.id}`, body)
onSaved()
} catch (e) { setError(e) }
}
return (
<Modal title={isNew ? t('new_user') : user.name} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('save')}</button></>}>
<Field label={t('name')}><input value={form.name} onChange={set('name')} /></Field>
<Field label={t('email')}><input type="email" value={form.email} onChange={set('email')} /></Field>
<Field label={t('department')} hint={t('user_department_hint')}>
<select value={form.department_id} onChange={set('department_id')}>
<option value="">{t('no_department')}</option>
{depts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</Field>
<Field label={t('role')}>
<select value={form.role} onChange={set('role')} disabled={user.id === me.id}>
{ROLES.map((r) => <option key={r} value={r}>{t(`role_${r}`)}</option>)}
</select>
</Field>
<Field label={t('language')}>
<select value={form.language} onChange={set('language')}><option value="pt">Português</option><option value="en">English</option></select>
</Field>
<Field label={isNew ? t('password') : t('new_password')} hint={isNew ? null : t('password_optional')}>
<input type="password" autoComplete="new-password" value={form.password} onChange={set('password')} />
</Field>
{!isNew && user.mfa_enabled && (
<div className="field">
<span className="field-label">{t('mfa')}</span>
{mfaReset ? <div className="notice">{t('mfa_reset_done')}</div> : <>
<span className="hint">{t('mfa_reset_text')}</span>
<div><button type="button" className="small" onClick={resetMfa}>{t('mfa_reset')}</button></div>
</>}
</div>
)}
{!isNew && user.id !== me.id && <label className="check"><input type="checkbox" checked={form.active} onChange={set('active')} /> {t('active')}</label>}
<ErrorNote error={error} />
</Modal>
)
}
@@ -0,0 +1,620 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import { dateTime, eur, exprForDisplay, num, pct, signedEur } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal, StatusBadge } from '../components/ui.jsx'
import EmailDialog from '../components/EmailDialog.jsx'
import { ImportYear } from './Years.jsx'
const ADJ_KINDS = ['year_rate', 'percent', 'factor', 'none']
const MONTHS = {
pt: ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'],
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
}
const QUARTERS = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
// [1, 2, 3, 4, 7] -> "Jan–Abr, Jul"
export function monthsLabel(list, lang) {
const names = MONTHS[lang] || MONTHS.pt
const runs = []
for (const m of [...list].sort((a, b) => a - b)) {
if (runs.length && m === runs[runs.length - 1][1] + 1) runs[runs.length - 1][1] = m
else runs.push([m, m])
}
return runs.map(([a, b]) => (a === b ? names[a - 1] : `${names[a - 1]}–${names[b - 1]}`)).join(', ')
}
// Same rules as the server: cents, the last month taking the rounding remainder.
function spread(total, chosen) {
const months = chosen.length ? [...chosen].sort((a, b) => a - b) : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const cents = Math.round(total * 100)
const share = Math.round(cents / months.length)
const out = Array(12).fill(0)
months.forEach((m) => { out[m - 1] = share / 100 })
out[months[months.length - 1] - 1] = (cents - share * (months.length - 1)) / 100
return out
}
// Evaluates the simple arithmetic the amount fields accept (12*839,67), for the live preview only.
function previewNumber(text) {
const expr = String(text ?? '').trim().replace(/^=/, '').replace(/\s/g, '').replace(/,/g, '.')
if (!expr || !/^[0-9+\-*/().]+$/.test(expr)) return null
try {
const v = Function(`"use strict";return (${expr})`)()
return Number.isFinite(v) ? v : null
} catch { return null }
}
export default function YearEditor() {
const { id } = useParams()
const location = useLocation()
const t = useT()
const { user, lang } = useAuth()
const [data, setData] = useState(null)
const [sites, setSites] = useState([])
const [categories, setCategories] = useState([])
const [siteFilter, setSiteFilter] = useState('')
const [search, setSearch] = useState('')
const [showEmpty, setShowEmpty] = useState(false)
const [editing, setEditing] = useState(null)
const [showHistory, setShowHistory] = useState(false)
const [deleting, setDeleting] = useState(false)
const [dialog, setDialog] = useState(null) // reopen | decline | email | upload
const [mail, setMail] = useState({ enabled: false })
useEffect(() => { api.get('/api/mail/status').then(setMail).catch(() => {}) }, [])
const navigate = useNavigate()
const [error, setError] = useState(null)
const [notice, setNotice] = useState(location.state?.notice || null)
useEffect(() => { if (location.state?.notice) setNotice(location.state.notice) }, [location.state])
const load = useCallback(() => {
const q = siteFilter ? `?site_id=${siteFilter}` : ''
return api.get(`/api/years/${id}${q}`).then(setData).catch(setError)
}, [id, siteFilter])
useEffect(() => { load() }, [load])
useEffect(() => { api.get('/api/sites').then(setSites) }, [])
const deptId = data?.department_id
useEffect(() => {
if (deptId) api.get(`/api/categories?department_id=${deptId}`).then(setCategories)
}, [deptId])
const actions = data?.actions || {}
const editable = !!actions.edit
const tv = useMemo(() => (data ? { year: data.year, prior: data.year - 1 } : {}), [data])
const saveLine = useCallback(async (lineId, patch) => {
try {
await api.put(`/api/lines/${lineId}`, patch)
setError(null)
await load()
return true
} catch (e) {
setError(e)
return false
}
}, [load])
const removeLine = async (line) => {
if (!window.confirm(t('confirm_delete', { name: line.name }))) return
await api.del(`/api/lines/${line.id}`).catch(setError)
load()
}
const transition = async (action) => {
try {
await api.post(`/api/years/${id}/status`, { action })
load()
} catch (e) { setError(e) }
}
const decide = async (accept) => {
try {
await api.post(`/api/reopen-requests/${data.reopen_request.id}/decide`, { accept })
if (accept) setNotice(t('reopened_notice'))
load()
} catch (e) { setError(e) }
}
const saveRate = async (value) => {
const rate = Number(String(value).replace(',', '.'))
if (Number.isNaN(rate)) return
await api.put(`/api/years/${id}`, { default_rate: rate / 100 }).catch(setError)
load()
}
if (!data) return <ErrorNote error={error} />
const q = search.trim().toLowerCase()
const visible = data.categories
.map((c) => ({ ...c, lines: q ? c.lines.filter((l) => (l.name + ' ' + l.description + ' ' + l.remarks).toLowerCase().includes(q)) : c.lines }))
.filter((c) => c.lines.length || (showEmpty && !q))
const catName = (c) => (lang === 'pt' && c.name_pt) || c.name_en
return (
<div>
<div className="page-head">
<div>
<h1>{data.title || data.year} <span className="dept-tag">{data.department}</span> <StatusBadge status={data.status} />{data.archived && <span className="badge"> 🔒 {t('archived_badge')}</span>}</h1>
<div className="muted small">
{data.submitted_by && t('submitted_by', { name: data.submitted_by }) + ' · ' + dateTime(data.submitted_at, lang)}
{data.approved_by && ' · ' + t('approved_by', { name: data.approved_by }) + ' · ' + dateTime(data.approved_at, lang)}
</div>
</div>
<div className="actions">
<button onClick={() => setShowHistory(true)}>{t('history')}</button>
<DownloadMenu id={data.id} lang={lang} />
{mail.enabled && <button onClick={() => setDialog('email')}>{t('send_email')}</button>}
{actions.upload && <button onClick={() => setDialog('upload')}>{t('upload_excel')}</button>}
{actions.submit && <button className="primary" onClick={() => transition('submit')}>{t('submit_review')}</button>}
{actions.return && <button onClick={() => transition('return')}>{t('return_draft')}</button>}
{actions.approve && <button className="primary" onClick={() => transition('approve')}>{t('approve')}</button>}
{actions.reopen && <button onClick={() => setDialog('reopen')}>{t('reopen')}</button>}
{!actions.reopen && actions.request_reopen && <button onClick={() => setDialog('reopen')}>{t('request_reopen')}</button>}
{actions.delete && <button className="danger" onClick={() => setDeleting(true)}>{t('delete_budget')}</button>}
</div>
</div>
{notice && <div className="notice" onClick={() => setNotice(null)}>{notice}</div>}
{data.archived && <div className="notice locked-note">🔒 {t('archived_note', { when: dateTime(data.archived_at, lang), name: data.archived_by || '—' })}</div>}
{data.reopen_request && (
<div className="notice reopen-note">
<div><strong>{t('reopen_pending', { name: data.reopen_request.requested_by, when: dateTime(data.reopen_request.requested_at, lang) })}</strong></div>
<div className="quote">“{data.reopen_request.reason}”</div>
{actions.decide_reopen && <div className="actions">
<button className="primary" onClick={() => decide(true)}>{t('reopen_accept')}</button>
<button onClick={() => setDialog('decline')}>{t('reopen_decline')}</button>
</div>}
</div>
)}
{data.source_file && !data.archived && <div className="muted small source-file">{t('loaded_from', { file: data.source_file })}</div>}
<Workflow data={data} />
{data.status !== 'draft' && <div className="notice muted">{t('locked_hint', { status: t(`status_${data.status}`).toLowerCase() })}</div>}
{data.status === 'draft' && !editable && user.department_id !== data.department_id &&
<div className="notice muted">{t('view_only_dept', { dept: data.department })}</div>}
<ErrorNote error={error} />
<div className="totals">
<div className="card stat"><div className="k">{t('prior_actual', tv)}</div><div className="v">{eur(data.costs.prior, lang)}</div></div>
<div className="card stat"><div className="k">{t('adjusted', tv)}</div><div className="v">{eur(data.costs.adjusted, lang)}</div></div>
<div className="card stat"><div className="k">{t('change')}</div>
<div className={'v ' + (data.costs.change > 0 ? 'up' : 'down')}>{signedEur(data.costs.change, lang)} <small>{pct(data.costs.change_pct, lang)}</small></div></div>
<div className="card stat"><div className="k">{t('default_rate')}</div>
<div className="v">{editable
? <RateInput value={data.default_rate * 100} lang={lang} onSave={saveRate} />
: num(data.default_rate * 100, lang) + '%'}</div></div>
</div>
<div className="toolbar">
<input type="search" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<select value={siteFilter} onChange={(e) => setSiteFilter(e.target.value)} aria-label={t('site')}>
<option value="">{t('all_sites')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
{editable && <label className="check"><input type="checkbox" checked={showEmpty} onChange={(e) => setShowEmpty(e.target.checked)} /> {t('show_empty')}</label>}
</div>
{visible.map(({ category, lines, totals }) => (
<section key={category.id} className="card cat">
<div className="cat-head">
<h2>{catName(category)} <span className="muted small">{lines.length === 1 ? t('lines_one') : t('lines_count', { n: lines.length })}</span></h2>
<div className="cat-totals">
<span>{eur(totals.prior, lang, 0)}</span><span className="arrow">→</span>
<strong>{eur(totals.adjusted, lang, 0)}</strong>
<span className={totals.change_pct == null ? 'muted' : totals.change > 0 ? 'up' : 'down'}>{pct(totals.change_pct, lang)}</span>
</div>
{editable && <button className="small" onClick={() => setEditing({ category_id: category.id })}>+ {t('add_line')}</button>}
</div>
{lines.length > 0 && (
<div className="table-wrap">
<table className="lines">
<colgroup>
<col /><col className="c-site" /><col className="c-amt" /><col className="c-amt" />
<col className="c-adj" /><col className="c-amt" /><col className="c-pct" /><col className="c-act" />
</colgroup>
<thead><tr>
<th>{t('line')}</th><th>{t('site')}</th>
<th className="num">{t('prior_actual', tv)}</th><th className="num">{t('base', tv)}</th>
<th>{t('adjustment')}</th><th className="num">{t('adjusted', tv)}</th><th className="num">{t('change_pct')}</th><th />
</tr></thead>
<tbody>
{lines.map((l) => (
<LineRow key={l.id} line={l} sites={sites} editable={editable} lang={lang} rate={data.default_rate}
onSave={saveLine} onOpen={() => setEditing(l)} onDelete={() => removeLine(l)} />
))}
</tbody>
</table>
</div>
)}
</section>
))}
{editing && <LineDialog line={editing} yearId={data.id} tv={tv} categories={categories} sites={sites} editable={editable}
rate={data.default_rate} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
{showHistory && <History yearId={data.id} requests={data.reopen_requests} onClose={() => setShowHistory(false)} />}
{(dialog === 'reopen' || dialog === 'decline') && (
<ReasonDialog kind={dialog} direct={actions.reopen} onClose={() => setDialog(null)}
onSubmit={async (text) => {
if (dialog === 'decline') await api.post(`/api/reopen-requests/${data.reopen_request.id}/decide`, { accept: false, note: text })
else {
const r = await api.post(`/api/years/${id}/reopen-requests`, { reason: text })
setNotice(r.reopened ? t('reopened_notice') : t('reopen_sent_notice'))
}
setDialog(null); load()
}} />
)}
{dialog === 'email' && <EmailDialog year={data} initial={['workbook', 'xlsx']} reportLang={lang} onClose={() => setDialog(null)} />}
{dialog === 'upload' && <ImportYear depts={[{ id: data.department_id, name: data.department }]} initialDept={String(data.department_id)} onClose={() => setDialog(null)} />}
{deleting && <DeleteBudget data={data} onClose={() => setDeleting(false)}
onDeleted={() => navigate('/', { state: { notice: t('budget_deleted', { dept: data.department, year: data.year }) } })} />}
</div>
)
}
function Workflow({ data }) {
const t = useT()
const steps = data.workflow.length ? data.workflow : [{ name: t('approve'), id: 'default' }]
const stepState = (i) => (data.status === 'approved' ? 'done'
: data.status === 'in_review' ? (i < (data.current_step || 0) ? 'done' : i === (data.current_step || 0) ? 'now' : 'todo') : 'todo')
return (
<ol className="flow" aria-label={t('workflow')}>
<li className={data.status === 'draft' ? 'now' : 'done'}>{t('status_draft')}</li>
{steps.map((s, i) => <li key={s.id ?? i} className={stepState(i)}>{s.name}</li>)}
<li className={data.status === 'approved' ? 'now done' : 'todo'}>{t('status_approved')}</li>
</ol>
)
}
function RateInput({ value, lang, onSave }) {
const [text, setText] = useState(num(value, lang, 2))
useEffect(() => setText(num(value, lang, 2)), [value, lang])
return (
<span className="rate"><input value={text} inputMode="decimal" onChange={(e) => setText(e.target.value)}
onBlur={() => text !== num(value, lang, 2) && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />%</span>
)
}
function ExprCell({ value, display, lang, editable, onSave, label }) {
const t = useT()
const shown = exprForDisplay(value, lang)
const [text, setText] = useState(shown)
const [bad, setBad] = useState(false)
const [focused, setFocused] = useState(false)
useEffect(() => setText(shown), [shown])
if (!editable) return <span>{display}</span>
const commit = async () => {
setFocused(false)
if (text.trim() === shown) return
const ok = await onSave(text)
setBad(!ok)
}
const isFormula = /[+\-*/()]/.test(String(value ?? '').replace(/^-/, ''))
return (
<input className={'cell num' + (bad ? ' bad' : '')} aria-label={label} title={isFormula ? `= ${shown}` : t('expr_hint')}
value={focused ? text : display} onFocus={() => setFocused(true)} onChange={(e) => setText(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
)
}
function LineRow({ line, sites, editable, lang, onSave, onOpen, onDelete }) {
const t = useT()
const manual = line.monthly_kind === 'manual'
const save = (patch) => onSave(line.id, patch)
const siteName = sites.find((s) => s.id === line.site_id)?.name || t('group')
const adjText = manual ? t('monthly_manual')
: line.adj_kind === 'year_rate' ? `${t('adj_year_rate')} (×${num(line.factor, lang, 3)})`
: line.adj_kind === 'percent' ? `${num(line.adj_value, lang, 2)}%`
: line.adj_kind === 'factor' ? `×${num(line.adj_value, lang, 3)}` : t('adj_none')
return (
<tr>
<td className="name">
{editable
? <TextCell value={line.name} onSave={(v) => save({ name: v })} label={t('name')} />
: line.name}
{(line.adj_reason || line.remarks) && <div className="muted small clip" title={[line.adj_reason, line.remarks].filter(Boolean).join(' · ')}>{line.adj_reason || line.remarks}</div>}
</td>
<td>
{editable ? (
<select className="cell" value={line.site_id ?? ''} aria-label={t('site')}
onChange={(e) => save({ site_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">{t('group')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : siteName}
</td>
<td className="num"><ExprCell value={line.prior_expr} display={num(line.prior, lang)} lang={lang} editable={editable} label={t('prior_actual')}
onSave={(v) => save({ prior_expr: v })} /></td>
<td className="num">{manual ? <span className="muted">{num(line.base, lang)}</span>
: <ExprCell value={line.base_expr} display={num(line.base, lang)} lang={lang} editable={editable} label={t('base')}
onSave={(v) => save({ base_expr: v })} />}</td>
<td>
{editable && !manual ? (
<div className="adj">
<select className="cell" value={line.adj_kind} aria-label={t('adjustment')} onChange={(e) => save({ adj_kind: e.target.value, adj_value: e.target.value === 'factor' ? line.factor : e.target.value === 'percent' ? +((line.factor - 1) * 100).toFixed(4) : 0 })}>
{ADJ_KINDS.map((k) => <option key={k} value={k}>{t(`adj_${k}`)}</option>)}
</select>
{(line.adj_kind === 'percent' || line.adj_kind === 'factor') && (
<NumberCell value={line.adj_value} lang={lang} onSave={(v) => save({ adj_value: v })} />
)}
</div>
) : <span className={manual ? 'muted' : ''}>{adjText}</span>}
{line.monthly_kind === 'selected' && line.monthly_months?.length > 0 &&
<div className="muted small" title={t('monthly_selected')}>{monthsLabel(line.monthly_months, lang)}</div>}
</td>
<td className="num strong">{num(line.adjusted, lang)}</td>
<td className={'num ' + (line.change_pct == null ? 'muted' : line.change > 0 ? 'up' : line.change < 0 ? 'down' : '')}>{pct(line.change_pct, lang)}</td>
<td className="row-actions">
<button className="icon" title={t('details')} aria-label={t('details')} onClick={onOpen}>⋯</button>
{editable && <button className="icon danger" title={t('delete')} aria-label={t('delete')} onClick={onDelete}>×</button>}
</td>
</tr>
)
}
function TextCell({ value, onSave, label }) {
const [text, setText] = useState(value)
useEffect(() => setText(value), [value])
return <input className="cell" aria-label={label} title={value} value={text} onChange={(e) => setText(e.target.value)}
onBlur={() => text.trim() && text !== value && onSave(text)} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
function NumberCell({ value, lang, onSave }) {
const [text, setText] = useState(num(value, lang, 3))
useEffect(() => setText(num(value, lang, 3)), [value, lang])
const commit = () => {
const n = Number(text.replace(/\s/g, '').replace(',', '.'))
if (!Number.isNaN(n) && text !== num(value, lang, 3)) onSave(n)
}
return <input className="cell num short" value={text} inputMode="decimal" onChange={(e) => setText(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} />
}
function LineDialog({ line, yearId, tv, categories, sites, editable, rate, onClose, onSaved }) {
const t = useT()
const { lang } = useAuth()
const isNew = !line.id
const [form, setForm] = useState({
name: line.name || '', description: line.description || '', remarks: line.remarks || '',
category_id: line.category_id, site_id: line.site_id ?? '',
prior_expr: exprForDisplay(line.prior_expr ?? '0', lang), base_expr: exprForDisplay(line.base_expr ?? '0', lang),
adj_kind: line.adj_kind || 'year_rate', adj_value: line.adj_value ?? 0, adj_reason: line.adj_reason || '',
monthly_kind: line.monthly_kind || 'even',
monthly_months: line.monthly_months || [],
monthly_values: (line.monthly_values || line.months || Array(12).fill(0)).map((v) => exprForDisplay(Math.round(v * 100) / 100, lang)),
})
const [error, setError] = useState(null)
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const months = MONTHS[lang] || MONTHS.pt
const selected = form.monthly_kind === 'selected'
const toggleMonth = (m) => {
const cur = new Set(form.monthly_months)
cur.has(m) ? cur.delete(m) : cur.add(m)
setForm({ ...form, monthly_months: [...cur].sort((a, b) => a - b) })
}
const setMonths = (list) => setForm({ ...form, monthly_months: list })
const toNum = (v) => Number(String(v).replace(/\s/g, '').replace(',', '.')) || 0
const submit = async () => {
const body = {
...form, category_id: Number(form.category_id), site_id: form.site_id ? Number(form.site_id) : null,
adj_value: toNum(form.adj_value),
monthly_values: form.monthly_kind === 'manual' ? form.monthly_values.map(toNum) : null,
monthly_months: selected ? form.monthly_months : null,
}
try {
if (isNew) await api.post(`/api/years/${yearId}/lines`, body)
else await api.put(`/api/lines/${line.id}`, body)
onSaved()
} catch (e) { setError(e) }
}
const ro = !editable
return (
<Modal wide title={isNew ? t('add_line') : form.name} onClose={onClose}
footer={<>
<button onClick={onClose}>{ro ? t('close') : t('cancel')}</button>
{!ro && <button className="primary" disabled={selected && form.monthly_months.length === 0} onClick={submit}>{t('save')}</button>}
</>}>
<fieldset disabled={ro} className="grid2">
<Field label={t('name')}><input value={form.name} onChange={set('name')} /></Field>
<Field label={t('category')}>
<select value={form.category_id} onChange={set('category_id')}>
{categories.filter((c) => c.active || c.id === form.category_id).map((c) => <option key={c.id} value={c.id}>{(lang === 'pt' && c.name_pt) || c.name_en}</option>)}
</select>
</Field>
<Field label={t('description')}><input value={form.description} onChange={set('description')} /></Field>
<Field label={t('site')}>
<select value={form.site_id} onChange={set('site_id')}>
<option value="">{t('group')}</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</Field>
<Field label={t('months')}>
<select value={form.monthly_kind} onChange={(e) => {
const kind = e.target.value
if (form.monthly_kind === 'manual' && kind !== 'manual') {
// leaving typed months keeps the year total as the base, with no further adjustment
const total = Math.round(form.monthly_values.reduce((a, v) => a + toNum(v), 0) * 100) / 100
setForm({ ...form, monthly_kind: kind, base_expr: exprForDisplay(total, lang), adj_kind: 'none' })
} else setForm({ ...form, monthly_kind: kind })
}}>
<option value="even">{t('monthly_even')}</option>
<option value="selected">{t('monthly_selected')}</option>
<option value="manual">{t('monthly_manual')}</option>
</select>
</Field>
<Field label={t('prior_actual', tv)} hint={t('expr_hint')}><input value={form.prior_expr} onChange={set('prior_expr')} /></Field>
{form.monthly_kind !== 'manual' && <>
<Field label={t('base', tv)} hint={t('expr_hint')}><input value={form.base_expr} onChange={set('base_expr')} /></Field>
<Field label={t('adjustment')} hint={form.adj_kind === 'year_rate' ? `×${num(1 + rate, lang, 3)}` : null}>
<div className="adj">
<select value={form.adj_kind} onChange={set('adj_kind')}>
{ADJ_KINDS.map((k) => <option key={k} value={k}>{t(`adj_${k}`)}</option>)}
</select>
{(form.adj_kind === 'percent' || form.adj_kind === 'factor') &&
<input className="short" inputMode="decimal" value={form.adj_value} onChange={set('adj_value')} />}
</div>
</Field>
<Field label={t('adj_reason')}><input value={form.adj_reason} onChange={set('adj_reason')} /></Field>
</>}
</fieldset>
{form.monthly_kind === 'manual' && (
<fieldset disabled={ro}>
<p className="hint">{t('monthly_hint_manual')}</p>
<div className="months">
{months.map((m, i) => (
<Field key={m} label={m}>
<input inputMode="decimal" value={form.monthly_values[i] ?? 0}
onChange={(e) => { const v = [...form.monthly_values]; v[i] = e.target.value; setForm({ ...form, monthly_values: v }) }} />
</Field>
))}
</div>
<div className="strong right">{t('adjusted', tv)}: {eur(form.monthly_values.reduce((a, v) => a + toNum(v), 0), lang)}</div>
</fieldset>
)}
{selected && (
<fieldset disabled={ro} className="month-pick">
<span className="field-label">{t('monthly_pick')}</span>
<div className="chips" role="group" aria-label={t('monthly_pick')}>
{months.map((m, i) => (
<button type="button" key={m} aria-pressed={form.monthly_months.includes(i + 1)}
className={'chip' + (form.monthly_months.includes(i + 1) ? ' on' : '')} onClick={() => toggleMonth(i + 1)}>{m}</button>
))}
</div>
<div className="chips quick">
{QUARTERS.map((q, i) => <button type="button" key={i} className="link small" onClick={() => setMonths(q)}>{t('quarter', { n: i + 1 })}</button>)}
<button type="button" className="link small" onClick={() => setMonths([1, 2, 3, 4, 5, 6])}>{t('half', { n: 1 })}</button>
<button type="button" className="link small" onClick={() => setMonths([7, 8, 9, 10, 11, 12])}>{t('half', { n: 2 })}</button>
<button type="button" className="link small" onClick={() => setMonths([])}>{t('clear')}</button>
</div>
<p className="hint">{form.monthly_months.length === 0 ? t('monthly_pick_none')
: t('monthly_pick_hint', { n: form.monthly_months.length, months: monthsLabel(form.monthly_months, lang) })}</p>
</fieldset>
)}
{form.monthly_kind !== 'manual' && (() => {
const base = previewNumber(form.base_expr)
if (base == null) return null
const factor = form.adj_kind === 'year_rate' ? 1 + rate : form.adj_kind === 'percent' ? 1 + toNum(form.adj_value) / 100
: form.adj_kind === 'factor' ? toNum(form.adj_value) : 1
const adjusted = Math.round(base * factor * 100) / 100
const values = spread(adjusted, selected ? form.monthly_months : [])
return (
<div className="month-strip">
{months.map((m, i) => <div key={m} className={values[i] ? '' : 'off'}><span className="muted small">{m}</span><span>{num(values[i], lang, 0)}</span></div>)}
</div>
)
})()}
<fieldset disabled={ro}>
<Field label={t('remarks')}><textarea rows={3} value={form.remarks} onChange={set('remarks')} /></Field>
</fieldset>
<ErrorNote error={error} />
</Modal>
)
}
function DownloadMenu({ id, lang }) {
const t = useT()
const q = `?lang=${lang}`
return (
<details className="menu">
<summary className="button">{t('download')} ▾</summary>
<div className="menu-list" onClick={(e) => { e.currentTarget.closest('details').open = false }}>
<a href={`/api/years/${id}/workbook.xlsx${q}`}>{t('budget_file')}<span className="muted small">{t('budget_file_hint')}</span></a>
<a href={`/api/years/${id}/reports/cost-report.pdf${q}`}>{t('report_cost')} · PDF</a>
<a href={`/api/years/${id}/reports/cost-report.xlsx${q}`}>{t('report_cost')} · Excel</a>
<a href={`/api/years/${id}/reports/budget.pdf${q}`}>{t('report_budget')} · PDF</a>
<a href={`/api/years/${id}/reports/budget.xlsx${q}`}>{t('report_budget')} · Excel</a>
</div>
</details>
)
}
function ReasonDialog({ kind, direct, onClose, onSubmit }) {
const t = useT()
const [text, setText] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const title = kind === 'decline' ? t('reopen_decline') : direct ? t('reopen') : t('request_reopen')
const ok = text.trim().length >= 5
const send = async () => {
setBusy(true)
try { await onSubmit(text.trim()) } catch (e) { setError(e); setBusy(false) }
}
return (
<Modal title={title} onClose={onClose} footer={<>
<button onClick={onClose}>{t('cancel')}</button>
<button className="primary" disabled={!ok || busy} onClick={send}>{kind === 'decline' ? t('reopen_decline') : direct ? t('reopen') : t('send_request')}</button>
</>}>
{kind !== 'decline' && <p className="muted small">{direct ? t('reopen_direct_hint') : t('reopen_request_hint')}</p>}
<Field label={kind === 'decline' ? t('decline_reason') + ' *' : t('reopen_reason') + ' *'} hint={!ok ? t('reason_min') : null}>
<textarea rows={4} value={text} onChange={(e) => setText(e.target.value)} autoFocus required />
</Field>
<ErrorNote error={error} />
</Modal>
)
}
function History({ yearId, requests, onClose }) {
const t = useT()
const { lang } = useAuth()
const [rows, setRows] = useState(null)
useEffect(() => { api.get(`/api/years/${yearId}/audit`).then(setRows) }, [yearId])
const describe = (r) => {
const changes = r.changes && r.action === 'update'
? Object.entries(r.changes).map(([k, [a, b]]) => `${k}: ${a ?? '∅'} → ${b ?? '∅'}`).join('; ')
: ''
return [r.summary, changes].filter(Boolean).join(' — ')
}
return (
<Modal wide title={t('history')} onClose={onClose}>
{requests?.length > 0 && <>
<h3 className="sub">{t('reopen_requests')}</h3>
<table className="plain">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('reopen_reason')}</th><th>{t('col_status')}</th></tr></thead>
<tbody>{requests.map((r) => (
<tr key={r.id}><td className="nowrap">{dateTime(r.requested_at, lang)}</td><td>{r.requested_by}</td><td className="small">{r.reason}</td>
<td className="small"><span className={'badge ' + (r.status === 'accepted' ? 'st-approved' : r.status === 'declined' ? 'st-failed' : 'st-in_review')}>{t(`req_${r.status}`)}</span>
{r.decided_by && <div className="muted">{r.decided_by} · {dateTime(r.decided_at, lang)}</div>}
{r.decision_note && <div>{r.decision_note}</div>}</td></tr>
))}</tbody>
</table>
<h3 className="sub">{t('changes')}</h3>
</>}
{rows && rows.length === 0 && <p className="muted">{t('nothing_yet')}</p>}
{rows && rows.length > 0 && (
<table className="plain">
<thead><tr><th>{t('when')}</th><th>{t('who')}</th><th>{t('what')}</th><th /></tr></thead>
<tbody>{rows.map((r, i) => (
<tr key={i}><td className="nowrap">{dateTime(r.at, lang)}</td><td>{r.user}</td><td>{r.entity} · {r.action}</td><td className="small">{describe(r)}</td></tr>
))}</tbody>
</table>
)}
</Modal>
)
}
function DeleteBudget({ data, onClose, onDeleted }) {
const t = useT()
const [typed, setTyped] = useState('')
const [error, setError] = useState(null)
const remove = async () => {
try {
await api.del(`/api/years/${data.id}`)
onDeleted()
} catch (e) { setError(e) }
}
return (
<Modal title={t('delete_budget')} onClose={onClose} footer={<>
<button onClick={onClose}>{t('cancel')}</button>
<button className="danger solid" disabled={typed.trim() !== String(data.year)} onClick={remove}>{t('delete_budget')}</button>
</>}>
<p>{t('delete_budget_text', { dept: data.department, year: data.year })}</p>
<Field label={t('delete_budget_confirm', { year: data.year })}>
<input value={typed} onChange={(e) => setTyped(e.target.value)} autoFocus inputMode="numeric" />
</Field>
<ErrorNote error={error} />
</Modal>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { atLeast, useAuth } from '../auth.js'
import { dateTime, eur, pct } from '../format.js'
import { useT } from '../i18n.js'
import { ErrorNote, Field, Modal, StatusBadge } from '../components/ui.jsx'
const FILTER_KEY = 'budget.deptFilter'
export default function Years() {
const t = useT()
const { user, lang } = useAuth()
const [years, setYears] = useState(null)
const [depts, setDepts] = useState([])
const [filter, setFilter] = useState(() => {
// Upper Management starts on all departments
const start = user.role === 'management' ? '' : String(user.department_id || '')
try { return localStorage.getItem(FILTER_KEY) ?? start } catch { return start }
})
const [dialog, setDialog] = useState(null)
const location = useLocation()
const [notice, setNotice] = useState(location.state?.notice || null)
const load = useCallback(() => api.get('/api/years').then(setYears), [])
const [archived, setArchived] = useState(null)
const showArchived = () => (archived ? setArchived(null) : api.get('/api/years?archived=true').then(setArchived))
useEffect(() => { load(); api.get('/api/departments').then(setDepts) }, [load])
useEffect(() => { try { localStorage.setItem(FILTER_KEY, filter) } catch { /* private mode */ } }, [filter])
// departments the user may create budgets in
const writable = atLeast(user, 'admin') ? depts.filter((d) => d.active)
: atLeast(user, 'editor') ? depts.filter((d) => d.id === user.department_id) : []
const shown = (years || []).filter((y) => !filter || String(y.department_id) === filter)
return (
<div>
<div className="page-head">
<h1>{t('years_title')}</h1>
<div className="actions">
{user.role !== 'management' && <button onClick={() => setDialog('template')}>{t('template')}</button>}
{writable.length > 0 && <>
<button onClick={() => setDialog('import')}>{t('import_xlsx')}</button>
<button className="primary" onClick={() => setDialog('new')}>{t('new_year')}</button>
</>}
</div>
</div>
<div className="toolbar">
<select value={filter} onChange={(e) => setFilter(e.target.value)} aria-label={t('department')}>
<option value="">{t('all_departments')}</option>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</div>
{user.role === 'management' && <p className="muted small">{t('management_scope')}</p>}
{notice && <div className="notice" onClick={() => setNotice(null)}>{notice}</div>}
{years && shown.length === 0 && <p className="muted">{t('no_years')}</p>}
<div className="year-grid">
{shown.map((y) => (
<Link to={`/years/${y.id}`} key={y.id} className="card year-card">
<div className="year-top"><span className="year-num">{y.year}</span><StatusBadge status={y.status} /></div>
{y.reopen_request && <div className="badge st-in_review reopen-badge">{t('reopen_requested_badge')}</div>}
<div className="dept-tag">{y.department}</div>
<div className="muted small">{y.title}</div>
<dl className="kv">
<dt>{t('prior_actual', { prior: y.year - 1 })}</dt><dd>{eur(y.costs.prior, lang, 0)}</dd>
<dt>{t('adjusted', { year: y.year })}</dt><dd className="strong">{eur(y.costs.adjusted, lang, 0)}</dd>
<dt>{t('change')}</dt><dd className={y.costs.change > 0 ? 'up' : 'down'}>{pct(y.costs.change_pct, lang)}</dd>
</dl>
</Link>
))}
</div>
<div className="archived-toggle">
<button className="link" onClick={showArchived}>{archived ? t('hide_archived') : t('show_archived')}</button>
</div>
{archived && (
archived.length === 0 ? <p className="muted small">{t('no_archived')}</p> : (
<div className="card">
<table className="plain">
<thead><tr><th>{t('department')}</th><th>{t('year')}</th><th>{t('title')}</th><th>{t('file')}</th><th>{t('archived_when')}</th><th className="right">{t('adjusted', { year: '' })}</th></tr></thead>
<tbody>{archived.filter((y) => !filter || String(y.department_id) === filter).map((y) => (
<tr key={y.id}><td>{y.department}</td><td>{y.year}</td><td><Link to={`/years/${y.id}`}>🔒 {y.title}</Link></td>
<td className="small muted">{y.source_file}</td><td className="small">{dateTime(y.archived_at, lang)} · {y.archived_by}</td>
<td className="right">{eur(y.costs.adjusted, lang, 0)}</td></tr>
))}</tbody>
</table>
</div>
)
)}
{dialog === 'new' && <NewYear years={years || []} depts={writable} initialDept={filter} onClose={() => setDialog(null)} />}
{dialog === 'import' && <ImportYear depts={writable} initialDept={filter} onClose={() => setDialog(null)} />}
{dialog === 'template' && <TemplateDialog depts={depts} years={years || []} initialDept={filter} onClose={() => setDialog(null)} />}
</div>
)
}
function pickDept(depts, initial, user) {
const ids = depts.map((d) => String(d.id))
if (ids.includes(initial)) return initial
if (ids.includes(String(user?.department_id))) return String(user.department_id)
return ids[0] || ''
}
function NewYear({ years, depts, initialDept, onClose }) {
const t = useT()
const { user } = useAuth()
const navigate = useNavigate()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const own = years.filter((y) => String(y.department_id) === deptId)
const latest = own[0]
const [form, setForm] = useState({})
useEffect(() => {
setForm({
year: latest ? latest.year + 1 : new Date().getFullYear() + 1,
default_rate: latest ? +(latest.default_rate * 100).toFixed(4) : 0,
copy_from_id: latest ? latest.id : '',
})
}, [deptId]) // eslint-disable-line react-hooks/exhaustive-deps
const [error, setError] = useState(null)
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async () => {
try {
const y = await api.post('/api/years', {
department_id: Number(deptId), year: Number(form.year),
default_rate: Number(String(form.default_rate).replace(',', '.')) / 100,
copy_from_id: form.copy_from_id ? Number(form.copy_from_id) : null,
})
navigate(`/years/${y.id}`)
} catch (e) { setError(e) }
}
return (
<Modal title={t('new_year')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" onClick={submit}>{t('create')}</button></>}>
<Field label={t('department')}>
<select value={deptId} onChange={(e) => setDeptId(e.target.value)}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
<Field label={t('year')}><input type="number" value={form.year ?? ''} onChange={set('year')} /></Field>
<Field label={t('default_rate')}><input inputMode="decimal" value={form.default_rate ?? ''} onChange={set('default_rate')} /></Field>
<Field label={t('copy_from')} hint={form.copy_from_id ? t('copy_hint') : null}>
<select value={form.copy_from_id ?? ''} onChange={set('copy_from_id')}>
<option value="">{t('copy_none')}</option>
{own.map((y) => <option key={y.id} value={y.id}>{y.year} · {y.title}</option>)}
</select>
</Field>
<ErrorNote error={error} />
</Modal>
)
}
// Upload a budget file. When the department already has a budget for that year, ask first: the current
// version is renamed with the date and time and locked, and the upload becomes the current budget.
export function ImportYear({ depts, initialDept, onClose }) {
const t = useT()
const { user, lang } = useAuth()
const navigate = useNavigate()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const [file, setFile] = useState(null)
const [year, setYear] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [conflict, setConflict] = useState(null)
const form = (replace) => {
const fd = new FormData()
fd.append('file', file)
fd.append('department_id', deptId)
if (year) fd.append('year', year)
fd.append('replace', replace ? 'true' : 'false')
return fd
}
const upload = async (replace) => {
setBusy(true); setError(null)
try {
const res = await api.post('/api/years/import', form(replace))
const warn = res.warnings?.length ? ` (${res.warnings.length} ⚠: ${res.warnings.slice(0, 3).join('; ')}${res.warnings.length > 3 ? '…' : ''})` : ''
const archived = res.archived ? ' ' + t('import_archived', { title: res.archived.title }) : ''
navigate(`/years/${res.year.id}`, { state: { notice: t('import_done', { n: res.lines, year: res.year.year }) + archived + warn } })
onClose()
} catch (e) { setError(e) } finally { setBusy(false) }
}
const submit = async () => {
if (!file) return
setBusy(true); setError(null)
try {
const check = await api.post('/api/years/import/check', form(false))
if (check.existing) { setConflict(check); setBusy(false); return }
} catch (e) { setError(e); setBusy(false); return }
await upload(false)
}
if (conflict) {
const ex = conflict.existing
return (
<Modal title={t('file_exists_title')} onClose={onClose} footer={ex.can_replace
? <><button onClick={() => setConflict(null)}>{t('cancel')}</button><button className="primary" disabled={busy} onClick={() => upload(true)}>OK</button></>
: <button onClick={onClose}>{t('close')}</button>}>
<div className="confirm-box">
<p><strong>{t(ex.same_file ? 'file_exists_same' : 'file_exists_budget', { file: conflict.filename, dept: conflict.department, year: conflict.year })}</strong></p>
{ex.can_replace ? <>
<p>{t('file_exists_replace', { title: ex.title })}</p>
<dl className="kv wide">
<dt>{t('current_version')}</dt><dd>{ex.title}{ex.source_file && <span className="muted small"> · {ex.source_file}</span>}<span className="muted small"> · {eur(ex.costs.adjusted, lang)}</span></dd>
<dt>{t('renamed_to')}</dt><dd>{ex.archived_title}{ex.archived_file && <span className="muted small"> · {ex.archived_file}</span>} <span className="badge">{t('locked')}</span></dd>
</dl>
</> : <p className="error">{t('file_exists_locked', { status: t(`status_${ex.status}`).toLowerCase() })}</p>}
</div>
<ErrorNote error={error} />
</Modal>
)
}
return (
<Modal title={t('import_xlsx')} onClose={onClose}
footer={<><button onClick={onClose}>{t('cancel')}</button><button className="primary" disabled={!file || busy} onClick={submit}>{t('import')}</button></>}>
{depts.length > 1 && (
<Field label={t('department')}>
<select value={deptId} onChange={(e) => setDeptId(e.target.value)}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
)}
<Field label={t('file')} hint={t('template_hint')}><input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files[0])} /></Field>
<Field label={t('year')} hint={t('import_year_hint')}><input type="number" value={year} onChange={(e) => setYear(e.target.value)} /></Field>
<ErrorNote error={error} />
</Modal>
)
}
function TemplateDialog({ depts, years, initialDept, onClose }) {
const t = useT()
const { user, lang } = useAuth()
const [deptId, setDeptId] = useState(() => pickDept(depts, initialDept, user))
const [year, setYear] = useState(new Date().getFullYear() + 1)
const [rate, setRate] = useState('0')
const [tplLang, setTplLang] = useState(lang)
const [from, setFrom] = useState('')
const own = years.filter((y) => String(y.department_id) === deptId)
const q = new URLSearchParams({ lang: tplLang, year: String(year), rate: String(rate).replace(',', '.') || '0' })
if (from) q.set('from_year_id', from)
const href = `/api/departments/${deptId}/template.xlsx?${q}`
return (
<Modal title={t('template_title')} onClose={onClose}
footer={<><button onClick={onClose}>{t('close')}</button><a className="button primary-link" href={href} onClick={() => setTimeout(onClose, 300)}>{t('download')}</a></>}>
<p className="muted small">{t('template_hint')}</p>
<Field label={t('department')}>
<select value={deptId} onChange={(e) => { setDeptId(e.target.value); setFrom('') }}>
{depts.map((d) => <option key={d.id} value={String(d.id)}>{d.name}</option>)}
</select>
</Field>
<div className="grid2">
<Field label={t('year')}><input type="number" value={year} onChange={(e) => setYear(e.target.value)} /></Field>
<Field label={t('year_rate_pct')}><input inputMode="decimal" value={rate} onChange={(e) => setRate(e.target.value)} /></Field>
</div>
<Field label={t('language')}>
<select value={tplLang} onChange={(e) => setTplLang(e.target.value)}><option value="pt">Português</option><option value="en">English</option></select>
</Field>
<Field label={t('prefill_from')}>
<select value={from} onChange={(e) => setFrom(e.target.value)}>
<option value="">{t('prefill_none')}</option>
{own.map((y) => <option key={y.id} value={y.id}>{y.year} · {y.title}</option>)}
</select>
</Field>
</Modal>
)
}
+281
View File
@@ -0,0 +1,281 @@
:root {
--bg: #f4f5f7; --surface: #ffffff; --surface-2: #f0f2f5; --text: #1d1f23; --muted: #6a6f78;
--border: #dcdfe4; --primary: #1f3b63; --primary-ink: #ffffff; --accent: #e8632b; --blue: #2f6fd6;
--up: #c2410c; --down: #15803d; --danger: #b42318; --focus: #2f6fd6; --notice: #eef4ff;
--shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .08);
color-scheme: light;
}
/* Dark colours: automatic from the computer unless the user picked light, or always when they picked dark */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #14161a; --surface: #1d2026; --surface-2: #252931; --text: #e8eaee; --muted: #9aa1ad;
--border: #343944; --primary: #5b8bd6; --primary-ink: #0d1117; --up: #fb923c; --down: #4ade80;
--danger: #f87171; --notice: #1f2a3d; --shadow: none; color-scheme: dark; }
}
:root[data-theme="dark"] { --bg: #14161a; --surface: #1d2026; --surface-2: #252931; --text: #e8eaee; --muted: #9aa1ad;
--border: #343944; --primary: #5b8bd6; --primary-ink: #0d1117; --up: #fb923c; --down: #4ade80;
--danger: #f87171; --notice: #1f2a3d; --shadow: none; color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
h1 { font-size: 22px; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: 16px; margin: 0; }
a { color: inherit; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.strong { font-weight: 600; }
.right { text-align: right; }
.center { justify-content: center; }
.nowrap { white-space: nowrap; }
.up { color: var(--up); } .down { color: var(--down); }
button, .button { font: inherit; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 7px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
button:hover, .button:hover { background: var(--surface-2); }
button.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); }
button.primary:hover { filter: brightness(1.08); }
button:disabled { opacity: .55; cursor: default; }
button.small { padding: 4px 10px; font-size: 13px; }
button.link { border: 0; background: none; padding: 4px 6px; color: var(--muted); }
button.icon { border: 0; background: none; padding: 2px 8px; font-size: 18px; line-height: 1; color: var(--muted); }
button.icon:hover { color: var(--text); background: var(--surface-2); }
button.icon.danger:hover { color: var(--danger); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border);
border-radius: 7px; padding: 7px 9px; width: 100%; }
input[type=checkbox] { width: auto; }
input:disabled, select:disabled, textarea:disabled { background: var(--surface-2); color: var(--muted); }
fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.shell { min-height: 100vh; }
.topbar { display: flex; align-items: center; gap: 24px; padding: 0 24px; height: 56px; background: var(--surface);
border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; white-space: nowrap; }
.brand.big { font-size: 18px; margin-bottom: 18px; }
.logo { display: inline-flex; align-items: flex-end; gap: 2px; width: 24px; height: 24px; padding: 4px; border-radius: 6px; background: var(--primary); }
.logo i { display: block; width: 4px; background: #fff; }
.logo i:nth-child(1) { height: 45%; } .logo i:nth-child(2) { height: 75%; background: var(--accent); } .logo i:nth-child(3) { height: 100%; }
.topbar nav { display: flex; gap: 4px; flex: 1; overflow-x: auto; }
.topbar nav a { padding: 8px 12px; border-radius: 8px; text-decoration: none; color: var(--muted); white-space: nowrap; }
.topbar nav a.active { color: var(--text); background: var(--surface-2); font-weight: 600; }
.userbox { display: flex; align-items: center; gap: 10px; }
.me { text-decoration: none; font-weight: 500; white-space: nowrap; }
.lang { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.lang button { border: 0; border-radius: 0; padding: 4px 9px; font-size: 12px; color: var(--muted); }
.lang button.on { background: var(--primary); color: var(--primary-ink); }
.content { max-width: 1440px; margin: 0 auto; padding: 24px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; box-shadow: var(--shadow); margin-bottom: 16px; }
.card.narrow { max-width: 480px; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.notice { background: var(--notice); border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; cursor: default; }
.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); border-radius: 8px; padding: 9px 12px; margin: 10px 0; }
.badge { font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 999px; background: var(--surface-2); color: var(--muted); white-space: nowrap; }
.badge.st-in_review { background: color-mix(in srgb, #d97706 18%, transparent); color: #b45309; }
.badge.st-approved { background: color-mix(in srgb, var(--down) 16%, transparent); color: var(--down); }
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.login { width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 12px; padding: 28px; }
.login h1 { font-size: 20px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.field-label { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.hint { font-size: 12px; color: var(--muted); }
.check { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.year-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.year-card { text-decoration: none; display: block; transition: border-color .15s; }
.year-card:hover { border-color: var(--primary); }
.year-top { display: flex; justify-content: space-between; align-items: center; }
.year-num { font-size: 26px; font-weight: 700; }
.kv { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; margin: 12px 0 0; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.totals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
.totals .card { margin-bottom: 0; }
.stat .k { font-size: 12.5px; color: var(--muted); }
.stat .v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
.stat .v small { font-size: 13px; font-weight: 600; }
.rate { display: inline-flex; align-items: center; gap: 4px; }
.rate input { width: 90px; font-size: 18px; font-weight: 700; padding: 2px 6px; }
.toolbar { display: flex; gap: 10px; margin: 4px 0 16px; align-items: center; flex-wrap: wrap; }
.toolbar input[type=search] { max-width: 320px; }
.toolbar select { width: auto; }
.cat { padding: 0; overflow: hidden; }
.cat-head { display: flex; align-items: center; gap: 16px; padding: 12px 16px; flex-wrap: wrap; }
.cat-head h2 { flex: 1; display: flex; gap: 10px; align-items: baseline; }
.cat-totals { display: flex; gap: 10px; align-items: baseline; font-variant-numeric: tabular-nums; }
.cat-totals .arrow { color: var(--muted); }
.table-wrap { overflow-x: auto; }
table.lines { width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 1160px; }
table.lines col.c-site { width: 120px; } table.lines col.c-amt { width: 135px; } table.lines col.c-adj { width: 200px; }
table.lines col.c-pct { width: 95px; } table.lines col.c-act { width: 72px; }
table.lines th { font-size: 12px; font-weight: 600; color: var(--muted); text-align: left; padding: 6px 8px; background: var(--surface-2);
border-top: 1px solid var(--border); white-space: nowrap; }
table.lines td { padding: 4px 8px; border-top: 1px solid var(--border); vertical-align: middle; }
table.lines th.num, table.lines td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.lines td.name { overflow: hidden; }
.clip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-left: 7px; }
.cell { border-color: transparent; background: transparent; padding: 5px 6px; border-radius: 6px; }
.cell:hover { border-color: var(--border); }
.cell:focus { border-color: var(--focus); background: var(--surface); }
.cell.num { text-align: right; font-variant-numeric: tabular-nums; }
.cell.bad { border-color: var(--danger); }
.short { width: 90px !important; min-width: 0 !important; }
.adj { display: flex; gap: 6px; align-items: center; }
.adj select { width: auto; }
.row-actions { white-space: nowrap; text-align: right; }
.adj .short { width: 72px !important; }
.backdrop { position: fixed; inset: 0; background: rgba(15, 18, 24, .45); display: grid; place-items: center; padding: 16px; z-index: 20; }
.modal { background: var(--surface); border-radius: 14px; width: 100%; max-width: 480px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
.modal.wide { max-width: 860px; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 8px; }
.modal-body { padding: 8px 20px; overflow-y: auto; }
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
.months { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0 10px; }
.month-strip { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px; margin: 4px 0 14px; font-variant-numeric: tabular-nums; }
.month-strip div { display: flex; flex-direction: column; align-items: flex-end; background: var(--surface-2); border-radius: 6px; padding: 4px 6px; font-size: 12px; }
table.plain { width: 100%; border-collapse: collapse; }
table.plain th { text-align: left; font-size: 12px; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--border); }
table.plain td { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.inline-add { display: flex; gap: 8px; margin-top: 12px; max-width: 480px; }
.report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.report { display: flex; flex-direction: column; gap: 10px; }
.report p { margin: 0; flex: 1; }
.viewer { padding: 0; overflow: hidden; }
.viewer-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 14px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.viewer iframe { width: 100%; height: 78vh; border: 0; background: #ececee; display: block; }
@media (max-width: 900px) {
.totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.topbar { gap: 12px; padding: 0 16px; }
.content { padding: 16px; }
}
@media (max-width: 640px) {
.topbar { flex-wrap: wrap; height: auto; padding: 8px 16px; gap: 6px 12px; }
.brand { flex: 1; }
.me { display: none; }
.topbar nav { order: 3; flex: 0 0 100%; }
.grid2 { grid-template-columns: 1fr; }
.months { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.month-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.totals { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat .v { font-size: 16px; }
}
.dept-tag { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 6px;
background: color-mix(in srgb, var(--blue) 14%, transparent); color: var(--blue); margin: 2px 0 4px; }
h1 .dept-tag { font-size: 13px; margin: 0; }
.flow { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 0; margin: 0 0 14px; }
.flow li { font-size: 12.5px; padding: 4px 12px 4px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface); }
.flow li.done { color: var(--down); border-color: color-mix(in srgb, var(--down) 40%, transparent); }
.flow li.done::before { content: "✓ "; }
.flow li.now { color: var(--primary-ink); background: var(--primary); border-color: var(--primary); font-weight: 600; }
.flow li.now.done::before { content: "✓ "; }
.dept .cat-head { border-bottom: 1px solid var(--border); }
.dept-name { flex: 1; }
.cell.title { font-size: 16px; font-weight: 600; max-width: 360px; }
.dept-body { padding: 4px 16px 16px; }
.dept-body h3 { font-size: 14px; margin: 10px 0 6px; }
.steps { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 8px; }
.step { border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }
.step-row { display: flex; gap: 8px; align-items: center; }
.step-row input { flex: 1; }
.step-row select { width: auto; }
.step-num { width: 24px; height: 24px; border-radius: 50%; background: var(--surface-2); display: grid; place-items: center; font-weight: 600; font-size: 12px; flex: none; }
.user-picks { display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 8px 0 2px 32px; }
.pick { font-size: 13px; }
.cat-head.flush { padding: 0 0 10px; }
select.auto { width: auto; }
a.primary-link { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); }
@media (max-width: 640px) { .step-row { flex-wrap: wrap; } .user-picks { padding-left: 0; } }
/* v3: delete, tabs, logs, email */
button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
button.danger.solid { background: var(--danger); border-color: var(--danger); color: #fff; }
button:disabled { opacity: .55; cursor: not-allowed; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted); padding: 8px 14px; }
.tabs button.on { color: var(--text); border-bottom-color: var(--primary); font-weight: 600; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 16px; }
.mail-form h2 { font-size: 15px; margin: 0 0 12px; }
.mail-form .hint { margin: 4px 0 10px; }
.check.big { font-weight: 600; font-size: 15px; }
.sticky-foot { position: sticky; bottom: 0; background: var(--bg); padding: 12px 0; z-index: 2; }
.sticky-foot input[type=email] { width: 240px; }
.spacer { flex: 1; }
.toolbar.wrap label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
.toolbar input[type=date] { width: auto; }
.card-head { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.pager { display: flex; align-items: center; gap: 8px; }
table.log td { vertical-align: top; }
table.log tr.bad td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
table.log tr.clickable { cursor: pointer; }
table.log tr.clickable:hover td { background: var(--surface-2); }
table.log tr.detail td { background: var(--surface-2); }
.badge.st-failed { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.kv.wide { grid-template-columns: max-content 1fr; margin: 0 0 8px; }
.kv.wide dd { text-align: left; }
.bad-text { color: var(--danger); }
.mail-body { white-space: pre-wrap; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin: 0; max-height: 260px; overflow: auto; }
.changes { margin: 4px 0 0; padding-left: 18px; color: var(--muted); }
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; }
@media (max-width: 760px) {
.grid3, .choices { grid-template-columns: 1fr; }
.sticky-foot input[type=email] { width: 100%; }
table.log { display: block; overflow-x: auto; }
}
/* v4: split over chosen months */
.month-pick { margin: 4px 0 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
.chip { min-width: 52px; padding: 5px 10px; border-radius: 999px; }
.chip.on { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); font-weight: 600; }
.chips.quick { gap: 2px; margin-top: 0; }
.month-strip div.off { opacity: .45; }
/* v5: download menu, reopen requests, archived versions */
details.menu { position: relative; }
details.menu > summary { list-style: none; cursor: pointer; }
details.menu > summary::-webkit-details-marker { display: none; }
.menu-list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 10; min-width: 270px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(16, 24, 40, .14); padding: 6px; display: flex; flex-direction: column; }
.menu-list a { padding: 8px 10px; border-radius: 6px; text-decoration: none; color: var(--text); display: flex; flex-direction: column; }
.menu-list a:hover { background: var(--surface-2); }
.reopen-note { border-left: 3px solid var(--accent); }
.reopen-note .quote { margin: 6px 0 8px; white-space: pre-wrap; }
.locked-note { border-left: 3px solid var(--muted); }
.source-file { margin: -6px 0 12px; }
.reopen-badge { display: inline-block; margin: 4px 0; }
h3.sub { font-size: 14px; margin: 4px 0 8px; }
h3.sub + table { margin-bottom: 16px; }
.confirm-box p { margin: 0 0 10px; }
.archived-toggle { margin: 16px 0 8px; }
/* v7: theme switch, profile pictures, MFA */
button.theme-btn { border: 1px solid var(--border); border-radius: 8px; padding: 4px 7px; line-height: 0; color: var(--muted); }
.login-tools { display: flex; justify-content: center; align-items: center; gap: 10px; }
table.plain .avatar { width: 24px; height: 24px; font-size: 10px; margin: -4px 0; }
.avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: none; display: inline-flex; align-items: center;
justify-content: center; background: var(--surface-2); color: var(--muted); font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
.avatar.lg { width: 88px; height: 88px; font-size: 30px; }
.me { display: inline-flex; align-items: center; gap: 8px; }
.namecell { display: inline-flex; align-items: center; gap: 10px; }
.avatar-row { display: flex; align-items: center; gap: 18px; margin: 6px 0 14px; flex-wrap: wrap; }
.avatar-row .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.section-title { font-size: 15px; margin: 22px 0 8px; padding-top: 16px; border-top: 1px solid var(--border); }
.badge.mfa-on { background: color-mix(in srgb, var(--down) 16%, transparent); color: var(--down); }
.qr { background: #fff; padding: 8px; border-radius: 8px; display: inline-block; line-height: 0; }
.qr svg { width: 200px; height: 200px; }
.secret { font-family: ui-monospace, Menlo, Consolas, monospace; letter-spacing: 1px; word-break: break-all; background: var(--surface-2);
padding: 6px 8px; border-radius: 6px; display: block; }
.codes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 15px; background: var(--surface-2); padding: 12px 16px; border-radius: 8px; margin: 10px 0; }
input.code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; letter-spacing: 4px; text-align: center; }
@media (max-width: 720px) { .me .me-name { display: none; } }
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8000' } },
})