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