Compare commits
119 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99fdf4f9ea | |||
|
|
003f456203 | ||
| 61a5b2e40d | |||
| c7caab0ef4 | |||
| 03c5f2538f | |||
| 5493e0c4e2 | |||
| ecbcf44f86 | |||
| 9afe23180f | |||
| 21bf1173ea | |||
| a764ae0d38 | |||
| fd88ba41ba | |||
| 4b6b4d96ef | |||
| 501051f9ed | |||
| 2a18d9be2d | |||
| 4e70eee0a8 | |||
| 52faa017f3 | |||
| f8b44ebb6e | |||
| 65815ef2e0 | |||
| 7d770f8b66 | |||
| 376ca4b477 | |||
| 7458e087e1 | |||
| 64b7d8d11b | |||
| 8971e443d8 | |||
| 0d324b89c4 | |||
| c5a3e0f696 | |||
| 9551399f5f | |||
| 18c7ef3ee9 | |||
| 66d0cd85ff | |||
| 4a5b5fb5fe | |||
| dbe249783e | |||
| 8742c25945 | |||
| e32a14557f | |||
| a6a46dd7b6 | |||
| 4923880a6e | |||
| d5790a08e9 | |||
| 097c16dc14 | |||
|
|
c8b92517e8 | ||
|
|
32bcd27a5a | ||
|
|
0bbbcc541b | ||
|
|
861d78eca2 | ||
|
|
e1192beca3 | ||
|
|
420506b074 | ||
|
|
6ca62db4a9 | ||
|
|
ec38cd5669 | ||
|
|
d23fcd6bdb | ||
|
|
820360df5b | ||
|
|
0a5b7bce10 | ||
|
|
6cb9c75a55 | ||
|
|
be662ee52e | ||
|
|
079ddfb0e7 | ||
|
|
fc906d6d55 | ||
|
|
55fbb1ae92 | ||
|
|
08c54b1f75 | ||
|
|
efb922eb0e | ||
|
|
4030cc90b2 | ||
|
|
c777dbb7b8 | ||
|
|
e3992298f0 | ||
|
|
457dbce6c2 | ||
|
|
1b49871ea0 | ||
|
|
f5bf4e720b | ||
|
|
2a61ffcdb4 | ||
|
|
3e0f826256 | ||
|
|
15d626cbbb | ||
|
|
4328c2f929 | ||
|
|
fb92cfc12c | ||
|
|
6f84964689 | ||
|
|
d604d5ae63 | ||
|
|
7d7be4f591 | ||
|
|
3896a1ac1a | ||
|
|
849945f339 | ||
|
|
f126d08da3 | ||
|
|
b34527730d | ||
|
|
90f095a8cf | ||
|
|
9ab8d3d7df | ||
|
|
3302d79c38 | ||
|
|
e3ecfce34c | ||
|
|
b97a80d8b9 | ||
|
|
21c4c73a62 | ||
|
|
d2a0ee65b3 | ||
|
|
530c556e95 | ||
|
|
66b25dd707 | ||
|
|
7b9ab383bc | ||
|
|
8fdc693f1a | ||
|
|
a07f1e4ab5 | ||
|
|
3acd1e0173 | ||
|
|
7717b70e77 | ||
|
|
3361e52541 | ||
|
|
6771efd0b0 | ||
|
|
640caf2617 | ||
|
|
d735fb4bd6 | ||
|
|
e233c1c18d | ||
|
|
4938bba3f3 | ||
|
|
8388b08a84 | ||
|
|
a04813ced2 | ||
|
|
0fbcbc0eca | ||
|
|
db337b3285 | ||
|
|
2ae7fb301c | ||
|
|
04ec221808 | ||
|
|
945985c969 | ||
|
|
b46cad5888 | ||
|
|
d06153f472 | ||
|
|
bcf7f0a2d0 | ||
|
|
438b72cba2 | ||
|
|
df742af8ef | ||
|
|
20b3a54ec7 | ||
|
|
2436f78023 | ||
|
|
3f4e1516a3 | ||
|
|
da47f9976f | ||
|
|
a293bdcd4b | ||
|
|
b353165f61 | ||
|
|
446f6effab | ||
|
|
3434c9e11f | ||
|
|
a8d53d9053 | ||
|
|
942cbb0624 | ||
|
|
82d4739b96 | ||
|
|
d1fa8e0200 | ||
|
|
72fa483e45 | ||
|
|
367644e38e | ||
|
|
731610cf3c |
75 changed files with 7277 additions and 796 deletions
246
.forgejo/workflows/release.yml
Normal file
246
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu
|
||||||
|
container: ubuntu:22.04
|
||||||
|
env:
|
||||||
|
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
steps:
|
||||||
|
- name: Install base tools and runtimes
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl wget git sudo ca-certificates gnupg
|
||||||
|
# Install Node.js 20
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
# Install Rust
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: |
|
||||||
|
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
|
||||||
|
|
||||||
|
- name: Install Windows cross-compile dependencies
|
||||||
|
run: |
|
||||||
|
apt-get install -y lld llvm clang nsis
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
cargo install --locked cargo-xwin
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Tauri
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
npx tauri build --bundles deb,rpm
|
||||||
|
|
||||||
|
- name: Build Tauri Windows
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
npx tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Collect release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-assets
|
||||||
|
cp src-tauri/target/release/bundle/deb/*.deb release-assets/ 2>/dev/null || true
|
||||||
|
cp src-tauri/target/release/bundle/deb/*.deb.sig release-assets/ 2>/dev/null || true
|
||||||
|
cp src-tauri/target/release/bundle/rpm/*.rpm release-assets/ 2>/dev/null || true
|
||||||
|
cp src-tauri/target/release/bundle/rpm/*.rpm.sig release-assets/ 2>/dev/null || true
|
||||||
|
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe release-assets/ 2>/dev/null || true
|
||||||
|
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true
|
||||||
|
ls -la release-assets/
|
||||||
|
|
||||||
|
- name: Copy changelogs to public
|
||||||
|
run: |
|
||||||
|
cp CHANGELOG.md public/CHANGELOG.md
|
||||||
|
cp CHANGELOG.fr.md public/CHANGELOG.fr.md
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
NOTES=$(sed -n "/^## \[${TAG}\]/,/^## /{/^## \[${TAG}\]/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
if [ -z "$NOTES" ]; then
|
||||||
|
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
fi
|
||||||
|
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
{
|
||||||
|
echo "notes<<CHANGELOG_EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "CHANGELOG_EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate latest.json
|
||||||
|
env:
|
||||||
|
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||||
|
|
||||||
|
LINUX_SIG=""
|
||||||
|
LINUX_DEB=""
|
||||||
|
WINDOWS_SIG=""
|
||||||
|
WINDOWS_EXE=""
|
||||||
|
|
||||||
|
for f in release-assets/*.deb.sig; do
|
||||||
|
[ -f "$f" ] && LINUX_SIG=$(cat "$f")
|
||||||
|
done
|
||||||
|
for f in release-assets/*.deb; do
|
||||||
|
[ -f "$f" ] && LINUX_DEB=$(basename "$f")
|
||||||
|
done
|
||||||
|
for f in release-assets/*-setup.exe.sig; do
|
||||||
|
[ -f "$f" ] && WINDOWS_SIG=$(cat "$f")
|
||||||
|
done
|
||||||
|
for f in release-assets/*-setup.exe; do
|
||||||
|
[ -f "$f" ] && WINDOWS_EXE=$(basename "$f")
|
||||||
|
done
|
||||||
|
|
||||||
|
PLATFORMS="{}"
|
||||||
|
if [ -n "$LINUX_SIG" ] && [ -n "$LINUX_DEB" ]; then
|
||||||
|
PLATFORMS=$(echo "$PLATFORMS" | jq \
|
||||||
|
--arg sig "$LINUX_SIG" \
|
||||||
|
--arg url "${BASE_URL}/${LINUX_DEB}" \
|
||||||
|
'. + {"linux-x86_64": {"signature": $sig, "url": $url}}')
|
||||||
|
fi
|
||||||
|
if [ -n "$WINDOWS_SIG" ] && [ -n "$WINDOWS_EXE" ]; then
|
||||||
|
PLATFORMS=$(echo "$PLATFORMS" | jq \
|
||||||
|
--arg sig "$WINDOWS_SIG" \
|
||||||
|
--arg url "${BASE_URL}/${WINDOWS_EXE}" \
|
||||||
|
'. + {"windows-x86_64": {"signature": $sig, "url": $url}}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg notes "$CHANGELOG_NOTES" \
|
||||||
|
--arg pub_date "$PUB_DATE" \
|
||||||
|
--argjson platforms "$PLATFORMS" \
|
||||||
|
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' \
|
||||||
|
> release-assets/latest.json
|
||||||
|
|
||||||
|
echo "Generated latest.json:"
|
||||||
|
cat release-assets/latest.json
|
||||||
|
|
||||||
|
- name: Create release and upload assets
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
API_URL="${GITHUB_SERVER_URL}/api/v1"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
BODY="${CHANGELOG_NOTES}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Windows** : Téléchargez le fichier \`.exe\` ci-dessous.
|
||||||
|
**Linux** : Téléchargez le fichier \`.deb\` ou \`.AppImage\` ci-dessous."
|
||||||
|
|
||||||
|
# Delete existing release for this tag (if any) to allow re-creation
|
||||||
|
EXISTING=$(curl -s "${API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}")
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Deleting existing release ID: $EXISTING_ID"
|
||||||
|
curl -s -X DELETE \
|
||||||
|
"${API_URL}/repos/${REPO}/releases/${EXISTING_ID}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create release
|
||||||
|
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||||
|
"${API_URL}/repos/${REPO}/releases" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "Simpl'Résultat ${TAG}" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||||
|
echo "Created release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Failed to create release"
|
||||||
|
echo "$RELEASE_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload all assets
|
||||||
|
for file in release-assets/*; do
|
||||||
|
FILENAME=$(basename "$file")
|
||||||
|
ENCODED_FILENAME=$(printf '%s' "$FILENAME" | jq -sRr @uri)
|
||||||
|
FILESIZE=$(stat -c%s "$file")
|
||||||
|
echo "Uploading: $FILENAME (${FILESIZE} bytes)"
|
||||||
|
HTTP_CODE=$(curl -w "%{http_code}" --max-time 300 -X POST \
|
||||||
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ENCODED_FILENAME}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${file}" \
|
||||||
|
-o /tmp/upload_response.json)
|
||||||
|
echo "HTTP $HTTP_CODE"
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Upload failed:"
|
||||||
|
cat /tmp/upload_response.json
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Release created: ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
||||||
|
|
||||||
|
- name: Publish latest.json to package registry
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# DELETE uses API v1, PUT uses the package upload API
|
||||||
|
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||||
|
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||||
|
|
||||||
|
# Delete the old package version to avoid 409 conflicts
|
||||||
|
echo "Deleting old package version (if any)..."
|
||||||
|
DEL_CODE=$(curl -s -w "%{http_code}" -X DELETE \
|
||||||
|
"${DELETE_URL}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-o /tmp/del_response.json)
|
||||||
|
echo "Delete HTTP $DEL_CODE"
|
||||||
|
# 204 = deleted, 404 = didn't exist (both OK)
|
||||||
|
if [ "$DEL_CODE" != "204" ] && [ "$DEL_CODE" != "404" ]; then
|
||||||
|
echo "WARNING: Unexpected delete response:"
|
||||||
|
cat /tmp/del_response.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading latest.json to package registry..."
|
||||||
|
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
|
||||||
|
"${UPLOAD_URL}/latest.json" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data-binary "@release-assets/latest.json" \
|
||||||
|
-o /tmp/pkg_response.json)
|
||||||
|
echo "Upload HTTP $HTTP_CODE"
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "ERROR: Failed to publish latest.json:"
|
||||||
|
cat /tmp/pkg_response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
|
|
@ -9,15 +9,80 @@ permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-windows:
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: windows-latest
|
|
||||||
- platform: ubuntu-22.04
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
# Extract section between ## [TAG] (or ## TAG) and next ## header
|
||||||
|
NOTES=$(sed -n "/^## \[${TAG}\]/,/^## /{/^## \[${TAG}\]/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
if [ -z "$NOTES" ]; then
|
||||||
|
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
fi
|
||||||
|
# Trim leading/trailing blank lines
|
||||||
|
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
# Write to multiline output
|
||||||
|
{
|
||||||
|
echo "notes<<CHANGELOG_EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "CHANGELOG_EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
||||||
|
releaseBody: |
|
||||||
|
${{ steps.changelog.outputs.notes }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Windows** : Téléchargez le fichier `.exe` ci-dessous et lancez l'installation.
|
||||||
|
|
||||||
|
> **Note :** Windows SmartScreen peut afficher un avertissement car l'application n'est pas signée.
|
||||||
|
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
|
||||||
|
|
||||||
|
> **Important :** Si vous aviez installé une version précédente via le fichier `.msi`, veuillez d'abord la désinstaller (Paramètres Windows > Applications) avant d'installer cette version.
|
||||||
|
|
||||||
|
**Linux** : Téléchargez le fichier `.deb` ou `.AppImage` ci-dessous.
|
||||||
|
releaseDraft: false
|
||||||
|
prerelease: false
|
||||||
|
updaterJsonPreferNsis: true
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
needs: build-windows
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -38,7 +103,6 @@ jobs:
|
||||||
workspaces: src-tauri
|
workspaces: src-tauri
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
@ -46,6 +110,22 @@ jobs:
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
NOTES=$(sed -n "/^## \[${TAG}\]/,/^## /{/^## \[${TAG}\]/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
if [ -z "$NOTES" ]; then
|
||||||
|
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
|
||||||
|
fi
|
||||||
|
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
{
|
||||||
|
echo "notes<<CHANGELOG_EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "CHANGELOG_EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and release
|
- name: Build and release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
|
|
@ -56,13 +136,19 @@ jobs:
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ github.ref_name }}
|
||||||
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
|
${{ steps.changelog.outputs.notes }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Windows** : Téléchargez le fichier `.msi` ou `.exe` ci-dessous et lancez l'installation.
|
**Windows** : Téléchargez le fichier `.exe` ci-dessous et lancez l'installation.
|
||||||
|
|
||||||
> **Note :** Windows SmartScreen peut afficher un avertissement car l'application n'est pas signée.
|
> **Note :** Windows SmartScreen peut afficher un avertissement car l'application n'est pas signée.
|
||||||
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
|
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
|
||||||
|
|
||||||
|
> **Important :** Si vous aviez installé une version précédente via le fichier `.msi`, veuillez d'abord la désinstaller (Paramètres Windows > Applications) avant d'installer cette version.
|
||||||
|
|
||||||
**Linux** : Téléchargez le fichier `.deb` ou `.AppImage` ci-dessous.
|
**Linux** : Téléchargez le fichier `.deb` ou `.AppImage` ci-dessous.
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -23,8 +23,13 @@ imports/*.csv
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
@ -40,5 +45,9 @@ imports/*.csv
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Auto-generated changelogs (synced from root by vite.config.ts)
|
||||||
|
public/CHANGELOG.md
|
||||||
|
public/CHANGELOG.fr.md
|
||||||
|
|
||||||
# Tauri generated
|
# Tauri generated
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
|
||||||
266
CHANGELOG.fr.md
Normal file
266
CHANGELOG.fr.md
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
# Journal des modifications
|
||||||
|
|
||||||
|
## [Non publié]
|
||||||
|
|
||||||
|
## [0.6.6]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34)
|
||||||
|
- Refactorisation de `buildPrevYearTotalMap` en inline et simplification des tests (#39)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Fichiers changelog synchronisés automatiquement via plugin Vite, copies obsolètes dans public/ supprimées (#37)
|
||||||
|
|
||||||
|
## [0.6.5]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Tableau de bord : menu déroulant de sélection du mois pour la section Budget vs Réel avec le dernier mois complété par défaut (#31)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Rapports et tableau de bord : police réduite dans le menu déroulant de mois pour un meilleur équilibre visuel (#31)
|
||||||
|
|
||||||
|
## [0.6.4]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
|
||||||
|
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
|
||||||
|
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
|
||||||
|
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
|
||||||
|
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
|
||||||
|
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
|
||||||
|
|
||||||
|
## [0.6.3]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Tableau de bord : histogramme empilé des dépenses par catégorie et par mois (#15)
|
||||||
|
- Tableau de bord : tableau budget vs réel du mois courant avec écart en $ et % (#15)
|
||||||
|
- Tableau de budget et rapport Budget vs Réel : formatage des sous-totaux de section avec poids visuel croissant (#14)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Tableau de bord : période par défaut changée de « mois » à « année à ce jour » (#15)
|
||||||
|
- Tableau de bord : section des transactions récentes supprimée (#15)
|
||||||
|
- Tous les rapports tabulaires : les lignes de grand total utilisent maintenant une police plus grande (text-sm), gras et bordure supérieure plus épaisse pour une meilleure hiérarchie visuelle (#14)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Rapport catégories dans le temps : toutes les catégories sont maintenant affichées (limite passée de 8 à 50) (#13)
|
||||||
|
- Graphique par catégorie : les noms sur l'axe Y utilisent maintenant la couleur du texte principal au lieu du gris (#13)
|
||||||
|
- Graphique catégories dans le temps : le texte de la légende utilise maintenant la couleur du texte principal au lieu d'hériter la couleur de la catégorie (#13)
|
||||||
|
|
||||||
|
## [0.6.2]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Tableau de budget : sous-totaux par section (dépenses, revenus, transferts) affichés après chaque groupe (#11)
|
||||||
|
- Rapport Budget vs Réel : sous-totaux par section avec réel, prévu, écart ($) et écart (%) par type (#11)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Page catégories : le panneau de détail reste maintenant visible lors du défilement d'une longue liste de catégories (#12)
|
||||||
|
|
||||||
|
## [0.6.1]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Page historique des versions : historique complet accessible depuis les Paramètres à tout moment
|
||||||
|
- Changelog bilingue : les notes de version s'affichent dans la langue choisie par l'utilisateur (FR/EN)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Visibilité des labels de graphiques : les montants sur les barres empilées utilisent maintenant du texte noir avec contour blanc pour un meilleur contraste (#8)
|
||||||
|
- Tableau de budget : les cellules éditables affichent maintenant un fond au survol, un curseur pointeur et une info-bulle pour clarifier l'interaction (#9)
|
||||||
|
|
||||||
|
## [0.6.0]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Rapports : bascule entre vue tableau et graphique pour les onglets Tendances, Par catégorie et Évolution
|
||||||
|
- Rapports : option « Afficher les montants » pour afficher les valeurs directement sur les barres et courbes
|
||||||
|
- Rapports : panneau de filtres avec cases à cocher par catégorie (recherche, tout sélectionner/désélectionner) et filtre par source
|
||||||
|
- Rapports : le filtre source s'applique au niveau SQL pour des totaux filtrés précis
|
||||||
|
- Rapports : en-têtes de tableau fixes sur tous les tableaux de rapports (Rapport dynamique, Budget vs Réel)
|
||||||
|
- Rapports : survol interactif — barres non survolées estompées, info-bulle filtrée sur la catégorie survolée
|
||||||
|
- Rapports : le survol de la légende met en évidence la catégorie sur tous les mois (graphique Évolution)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Tableau des transactions : l'icône de commentaire devient orange (comme l'icône de ventilation) quand une note est présente (#7)
|
||||||
|
|
||||||
|
## [0.5.0]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Gestion d'erreurs : intercepte les plantages React et affiche une page d'erreur au lieu d'un écran blanc
|
||||||
|
- Délai de démarrage (10 s) sur la connexion à la base de données — affiche une page d'erreur au lieu d'un indicateur de chargement infini
|
||||||
|
- Page d'erreur avec « Rafraîchir », « Vérifier les mises à jour » et liens de contact
|
||||||
|
- Visionneuse de journaux dans les paramètres — capture la sortie console, filtrable par niveau, copiable, persiste entre les rafraîchissements
|
||||||
|
- Licence GPL-3.0 — le projet est maintenant open source
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Modale détail de rapport : colonnes triables — cliquez sur les en-têtes pour trier par date, description ou montant (#1)
|
||||||
|
- Modale détail de rapport : bascule pour afficher/masquer la colonne des montants (#3)
|
||||||
|
- Tableau de budget : les en-têtes de colonnes restent fixes lors du défilement vertical (#2)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Mise à jour automatique sur Linux : le champ version de `latest.json` n'a plus le préfixe `v`, téléversement au registre de paquets plus robuste
|
||||||
|
- Nouvelle tentative au démarrage : la connexion BD réessaie jusqu'à 3 fois avant d'afficher la page d'erreur (corrige l'échec au premier lancement sur Windows)
|
||||||
|
- Somme de contrôle de migration : répare automatiquement la somme de contrôle obsolète de la migration 1 au démarrage
|
||||||
|
|
||||||
|
## [0.4.4]
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Le binaire Linux est maintenant compatible avec glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — le CI compile dans un conteneur Ubuntu 22.04
|
||||||
|
|
||||||
|
## [0.4.3]
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Le point de terminaison de mise à jour automatique utilise maintenant le registre de paquets Forgejo pour une URL stable
|
||||||
|
- Les signatures Linux (.AppImage.sig) sont maintenant correctement collectées dans le CI
|
||||||
|
- Toutes les signatures de plateforme (.deb.sig, .rpm.sig) sont maintenant incluses dans les assets de la release
|
||||||
|
|
||||||
|
## [0.4.2]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- La mise à jour automatique pointe maintenant vers l'instance Forgejo auto-hébergée
|
||||||
|
- Les builds Windows sont maintenant compilés en croisé via cargo-xwin
|
||||||
|
|
||||||
|
## [0.4.1]
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Application bloquée sur un indicateur de chargement infini après mise à jour depuis v0.3.x (somme de contrôle de migration incompatible sur seed_categories.sql)
|
||||||
|
- Les erreurs de connexion BD sont maintenant journalisées dans la console au lieu d'échouer silencieusement
|
||||||
|
|
||||||
|
## [0.4.0]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)
|
||||||
|
- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »
|
||||||
|
- Budget : sous-totaux intermédiaires et indentation 3 niveaux pour les catégories imbriquées
|
||||||
|
- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories
|
||||||
|
- Catégories : la validation de profondeur empêche la création d'un 4e niveau
|
||||||
|
- Données initiales : Assurances divisées en Assurance-auto, Assurance-habitation, Assurance-vie
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus
|
||||||
|
- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot
|
||||||
|
|
||||||
|
## [0.3.11]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau
|
||||||
|
|
||||||
|
## [0.3.10]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)
|
||||||
|
- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)
|
||||||
|
- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)
|
||||||
|
|
||||||
|
## [0.3.9]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs
|
||||||
|
- Suppression de mots-clés depuis la vue « Tous les mots-clés »
|
||||||
|
|
||||||
|
## [0.3.8]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord
|
||||||
|
- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail
|
||||||
|
- Affichage des notes de version du CHANGELOG dans les releases et le système de mise à jour
|
||||||
|
|
||||||
|
## [0.3.7]
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Suppression du bundle MSI pour éviter le conflit de chemin d'installation du système de mise à jour
|
||||||
|
- Changement du mode d'installation Windows à basicUi
|
||||||
|
- Amélioration de la visibilité de l'indicateur de ventilation et de la mise en page des ajustements
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Support Linux** : ajout des builds Linux (`.deb`, `.rpm`, `.AppImage`) au workflow de release
|
||||||
|
- **Ventilations sur la page Ajustements** : visualisez les ajustements de ventilation des transactions dans une section dédiée
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Correction de cas limites de détection automatique CSV
|
||||||
|
- Suppression de l'accent dans productName pour la compatibilité `.deb` Linux
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Toujours afficher le sélecteur de profil dans la barre latérale (#2)
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Profils multiples** : créez plusieurs profils avec des bases de données séparées, des noms et couleurs personnalisés
|
||||||
|
- **Protection par NIP** : protégez les profils avec un NIP numérique optionnel
|
||||||
|
- **Sélecteur de profil** : changement rapide de profil depuis la barre latérale
|
||||||
|
- **Glisser-déposer les catégories** : réordonnez les catégories ou changez le parent par glisser-déposer dans l'arborescence
|
||||||
|
- **Ventilation des transactions** : ventilez une transaction sur plusieurs catégories avec des montants ajustables
|
||||||
|
|
||||||
|
## 0.2.10
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Sélection rapide de période** : boutons de filtre rapide (Ce mois, Mois dernier, etc.) sur la page Transactions
|
||||||
|
- **Rapport Budget vs Réel** : tableau comparatif mensuel et cumulatif annuel dans les Rapports
|
||||||
|
- **Sous-totaux de catégories parentes** : la page Budget affiche les sous-totaux agrégés pour les catégories parentes
|
||||||
|
- **Guide utilisateur** : documentation complète accessible depuis les Paramètres, imprimable en PDF
|
||||||
|
|
||||||
|
### Améliorations
|
||||||
|
- Persistance de la sélection de modèle et ajout du bouton Mettre à jour le modèle
|
||||||
|
- Ne plus pré-sélectionner les fichiers déjà importés à l'entrée de la configuration source
|
||||||
|
- Rendre les imports de données de paramètres visibles dans l'historique d'import
|
||||||
|
- Remplacer les boutons de suppression par modèle par un seul bouton sur la sélection
|
||||||
|
- Remplacer l'icône de rafraîchissement par une icône de sauvegarde sur le bouton de mise à jour du modèle
|
||||||
|
- Ajout de la convention de signe à la page budget
|
||||||
|
|
||||||
|
## 0.2.9
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Permettre les fichiers à contenu identique avec des noms différents (#1)
|
||||||
|
|
||||||
|
## 0.2.8
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Export/import de données** : exportez et importez vos données (transactions, catégories, ou les deux) avec chiffrement AES-256-GCM optionnel (#3)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Détection de doublons inter-fichiers et suivi d'import par fichier
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Modèles de configuration d'import** : sauvegardez et chargez les configurations de source d'import comme modèles réutilisables
|
||||||
|
- **Grille de budget 12 mois** : vue budgétaire annuelle complète avec cellules mensuelles et totaux annuels
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Corrections du budget et des catégories
|
||||||
|
- Problème de somme de contrôle de migration (schema.sql ne doit pas être modifié après la release initiale)
|
||||||
|
|
||||||
|
## 0.2.3
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
- **Motifs de graphiques** : ajout de motifs de remplissage SVG (lignes diagonales, points, hachures, etc.) pour différencier les catégories dans les graphiques au-delà de la couleur
|
||||||
|
- **Menu contextuel des graphiques** : clic-droit sur une catégorie dans un graphique pour la masquer ou voir ses transactions dans une fenêtre de détail
|
||||||
|
- **Catégories masquées** : les catégories masquées apparaissent comme des puces au-dessus des graphiques avec un bouton « Tout afficher »
|
||||||
|
- **Modale de détail des transactions** : visualisez toutes les transactions composant le total d'une catégorie directement depuis n'importe quel graphique
|
||||||
|
- **Aperçu d'import en popup** : l'aperçu des données est maintenant une modale popup au lieu d'une étape séparée de l'assistant
|
||||||
|
- **Vérification directe des doublons** : nouveau bouton « Vérifier les doublons » sur la page de configuration d'import
|
||||||
|
|
||||||
|
### Améliorations
|
||||||
|
- Flux de l'assistant d'import simplifié : configuration source → vérification des doublons (l'aperçu est optionnel via popup)
|
||||||
|
- Le bouton retour de la vérification des doublons retourne maintenant à la configuration source
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
- Mise à jour de version
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
- Ajout de la vue « Tous les mots-clés » sur la page Catégories
|
||||||
|
- Ajout du mode sombre avec palette de gris chauds
|
||||||
|
- Correction des catégories orphelines, persistance de has_header pour les imports, ajout de la réinitialisation
|
||||||
|
- Ajout des pages Budget et Ajustements
|
||||||
240
CHANGELOG.md
240
CHANGELOG.md
|
|
@ -1,5 +1,245 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.6]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
|
||||||
|
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
|
||||||
|
|
||||||
|
## [0.6.5]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
|
||||||
|
|
||||||
|
## [0.6.4]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
|
||||||
|
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
|
||||||
|
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
|
||||||
|
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
|
||||||
|
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
|
||||||
|
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
|
||||||
|
|
||||||
|
## [0.6.3]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dashboard: expenses over time stacked bar chart by category and month (#15)
|
||||||
|
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
|
||||||
|
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dashboard: default period changed from "month" to "year to date" (#15)
|
||||||
|
- Dashboard: removed recent transactions section (#15)
|
||||||
|
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
|
||||||
|
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
|
||||||
|
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
|
||||||
|
|
||||||
|
## [0.6.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
|
||||||
|
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
|
||||||
|
|
||||||
|
## [0.6.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Changelog page: full version history accessible from Settings at any time
|
||||||
|
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
|
||||||
|
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
|
||||||
|
|
||||||
|
## [0.6.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
|
||||||
|
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
|
||||||
|
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
|
||||||
|
- Reports: source filter applies at SQL level for accurate filtered totals
|
||||||
|
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
|
||||||
|
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
|
||||||
|
- Reports: legend hover highlights category across all months (Over Time chart)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
|
||||||
|
|
||||||
|
## [0.5.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Error boundary catches React crashes and displays an error page instead of a white screen
|
||||||
|
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
|
||||||
|
- Error page with "Refresh", "Check for updates", and contact/issue links
|
||||||
|
- Log viewer in settings page — captures console output, filterable by level, copyable, persists across refresh
|
||||||
|
- GPL-3.0 license — project is now open source
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Report detail modal: sortable columns — click headers to sort by date, description, or amount (#1)
|
||||||
|
- Report detail modal: toggle to show/hide amounts column (#3)
|
||||||
|
- Budget table: column headers stay fixed when scrolling vertically (#2)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Auto-updater on Linux: `latest.json` version field no longer has `v` prefix, package registry upload is more robust
|
||||||
|
- Startup retry: DB connection retries up to 3 times before showing error page (fixes first-launch failure on Windows)
|
||||||
|
- Migration checksum mismatch: automatically repairs stale migration 1 checksum on startup
|
||||||
|
|
||||||
|
## [0.4.4]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Linux binary now compatible with glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — CI builds in Ubuntu 22.04 container
|
||||||
|
|
||||||
|
## [0.4.3]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Auto-updater endpoint now uses Forgejo package registry for stable URL
|
||||||
|
- Linux updater signatures (.AppImage.sig) now correctly collected in CI
|
||||||
|
- All platform signatures (.deb.sig, .rpm.sig) now included in release assets
|
||||||
|
|
||||||
|
## [0.4.2]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Auto-updater now points to self-hosted Forgejo instance
|
||||||
|
- Windows builds now cross-compiled via cargo-xwin
|
||||||
|
|
||||||
|
## [0.4.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- App stuck on infinite spinner after updating from v0.3.x (migration checksum mismatch on seed_categories.sql)
|
||||||
|
- DB connection errors now logged to console instead of silently failing
|
||||||
|
|
||||||
|
## [0.4.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Categories: support for 3 levels of hierarchy (e.g., Dépenses récurrentes → Assurances → Assurance-auto)
|
||||||
|
- Dynamic Report: new "Category (Level 3)" pivot field
|
||||||
|
- Budget: intermediate subtotals and 3-level indentation for nested categories
|
||||||
|
- Categories: automatic `is_inputable` management when creating/deleting subcategories
|
||||||
|
- Categories: depth validation prevents creating a 4th level
|
||||||
|
- Seed data: Assurances split into Assurance-auto, Assurance-habitation, Assurance-vie
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly
|
||||||
|
- Auto-categorization: pre-compile regex patterns for better batch performance
|
||||||
|
|
||||||
|
## [0.3.11]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dynamic Report: support multiple column dimensions (composite column keys)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters
|
||||||
|
|
||||||
|
## [0.3.10]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)
|
||||||
|
- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)
|
||||||
|
- "This year" period option in reports and dashboard (Jan 1 to today)
|
||||||
|
|
||||||
|
## [0.3.9]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values
|
||||||
|
- Delete keywords from the "All Keywords" view
|
||||||
|
|
||||||
|
## [0.3.8]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Custom date range picker for reports and dashboard
|
||||||
|
- Toggle to position subtotals above or below detail rows
|
||||||
|
- Display release notes from CHANGELOG in GitHub releases and in-app updater
|
||||||
|
|
||||||
|
## [0.3.7]
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Remove MSI bundle to prevent updater install path conflict
|
||||||
|
- Change Windows updater installMode to basicUi
|
||||||
|
- Improve split indicator visibility and adjustments layout
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Linux support**: Add Linux build (`.deb`, `.rpm`, `.AppImage`) to release workflow
|
||||||
|
- **Transaction splits on Adjustments page**: View transaction split adjustments in a dedicated section on the Adjustments page
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Fix CSV auto-detect edge cases
|
||||||
|
- Remove accent from productName for Linux `.deb` compatibility
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Always show profile switcher in sidebar (#2)
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Multiple profiles**: Create multiple profiles with separate databases, custom names, and colors
|
||||||
|
- **PIN protection**: Protect profiles with an optional numeric PIN
|
||||||
|
- **Profile switcher**: Quick profile switching from the sidebar
|
||||||
|
- **Drag-and-drop categories**: Reorder categories or change parent via drag-and-drop in the category tree
|
||||||
|
- **Transaction splits**: Split a transaction across multiple categories with adjustable amounts
|
||||||
|
|
||||||
|
## 0.2.10
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Period quick-select**: Add quick period filter buttons (This month, Last month, etc.) on the Transactions page
|
||||||
|
- **Budget vs Actual report**: Monthly and year-to-date comparison table in Reports
|
||||||
|
- **Parent category subtotals**: Budget page shows aggregated subtotals for parent categories
|
||||||
|
- **User guide**: Complete documentation page accessible from Settings, printable to PDF
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Persist template selection and add Update template button
|
||||||
|
- Don't pre-select already-imported files when entering source config
|
||||||
|
- Make settings data imports visible in Import History
|
||||||
|
- Replace per-template delete buttons with single delete on selection
|
||||||
|
- Replace refresh icon with save icon on update template button
|
||||||
|
- Add sign convention to budget page
|
||||||
|
|
||||||
|
## 0.2.9
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Allow duplicate-content files with different names (#1)
|
||||||
|
|
||||||
|
## 0.2.8
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Data export/import**: Export and import your data (transactions, categories, or both) with optional AES-256-GCM encryption (#3)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Cross-file duplicate detection and per-file import tracking
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Import config templates**: Save and load import source configurations as reusable templates
|
||||||
|
- **12-month budget grid**: Full year budget view with monthly cells and annual totals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Budget and category fixes
|
||||||
|
- Migration checksum issue (schema.sql must not be modified after initial release)
|
||||||
|
|
||||||
## 0.2.3
|
## 0.2.3
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|
|
||||||
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# CLAUDE.md — Simpl'Résultat
|
||||||
|
|
||||||
|
## Contexte du projet
|
||||||
|
|
||||||
|
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
|
||||||
|
|
||||||
|
**Stack technique :** Tauri v2 + React 19 + TypeScript + Tailwind CSS v4
|
||||||
|
**Backend :** Rust (commandes Tauri)
|
||||||
|
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||||
|
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||||
|
**Plateformes :** Windows, Linux
|
||||||
|
**Version actuelle :** 0.6.3
|
||||||
|
**Licence :** GPL-3.0-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principes fondamentaux
|
||||||
|
|
||||||
|
### Privacy-first — NON NÉGOCIABLE
|
||||||
|
- Zéro donnée envoyée vers un serveur tiers
|
||||||
|
- Tout le traitement CSV et toutes les données financières restent en local
|
||||||
|
- Aucune télémétrie, aucun analytics cloud
|
||||||
|
|
||||||
|
### Précision financière
|
||||||
|
- Toujours valider les montants selon les règles de parsing configurables (gestion des virgules/points, espaces, symboles monétaires)
|
||||||
|
- Gérer l'encodage des fichiers CSV (UTF-8, Windows-1252, ISO-8859-15)
|
||||||
|
|
||||||
|
### Internationalisation (i18n)
|
||||||
|
- Toute chaîne affichée à l'utilisateur doit passer par le système i18n (i18next + react-i18next)
|
||||||
|
- Jamais de texte en dur dans les composants React
|
||||||
|
- Fichiers de traduction : `src/i18n/locales/fr.json` et `src/i18n/locales/en.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture & structure du code
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # 53 composants React organisés par domaine
|
||||||
|
│ ├── adjustments/ # Ajustements
|
||||||
|
│ ├── budget/ # Budget
|
||||||
|
│ ├── categories/ # Catégories hiérarchiques
|
||||||
|
│ ├── dashboard/ # Tableau de bord
|
||||||
|
│ ├── import/ # Wizard d'import (13 composants)
|
||||||
|
│ ├── layout/ # AppShell, Sidebar
|
||||||
|
│ ├── profile/ # Profils (PIN, formulaire, switcher)
|
||||||
|
│ ├── reports/ # Graphiques et rapports
|
||||||
|
│ ├── settings/ # Paramètres
|
||||||
|
│ ├── shared/ # Composants réutilisables
|
||||||
|
│ └── transactions/ # Transactions
|
||||||
|
├── contexts/ # ProfileContext (état global profil)
|
||||||
|
├── hooks/ # 12 hooks custom (useReducer)
|
||||||
|
├── pages/ # 11 pages
|
||||||
|
├── services/ # 14 services métier
|
||||||
|
├── shared/ # Types et constantes partagés
|
||||||
|
├── utils/ # Utilitaires (parsing, CSV, charts)
|
||||||
|
├── i18n/ # Config i18next + locales FR/EN
|
||||||
|
├── App.tsx # Router principal (react-router-dom)
|
||||||
|
└── main.tsx # Point d'entrée
|
||||||
|
|
||||||
|
src-tauri/
|
||||||
|
├── src/
|
||||||
|
│ ├── commands/ # 3 modules, 17 commandes Tauri
|
||||||
|
│ │ ├── fs_commands.rs # Système de fichiers (6 commandes)
|
||||||
|
│ │ ├── export_import_commands.rs # Export/import chiffré (5 commandes)
|
||||||
|
│ │ └── profile_commands.rs # Gestion des profils (6 commandes)
|
||||||
|
│ ├── database/ # Schémas SQL et migrations
|
||||||
|
│ │ ├── schema.sql # Schéma initial (v1)
|
||||||
|
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
||||||
|
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||||
|
│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins
|
||||||
|
│ └── main.rs
|
||||||
|
└── Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles d'architecture :**
|
||||||
|
- La logique métier va dans `services/`, jamais directement dans les composants
|
||||||
|
- L'état de chaque domaine est géré par un hook `useReducer` dédié dans `hooks/`
|
||||||
|
- Les composants React sont responsables de l'affichage uniquement
|
||||||
|
- Toute opération sur les fichiers système passe par les commandes Tauri (Rust)
|
||||||
|
- Les requêtes SQL passent par les services TypeScript via `tauri-plugin-sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalités principales
|
||||||
|
|
||||||
|
- **Import CSV** : wizard multi-étapes, détection auto de l'encodage/délimiteur, templates de config, déduplication par fichier
|
||||||
|
- **Catégorisation** : automatique (mots-clés avec priorité) et manuelle, drag-and-drop pour réorganiser
|
||||||
|
- **Transactions** : filtrage, tri, split sur plusieurs catégories, notes
|
||||||
|
- **Budget** : grille 12 mois, templates réutilisables, budget vs réel
|
||||||
|
- **Rapports** : tendances mensuelles, répartition par catégorie, évolution dans le temps, graphiques interactifs (SVG patterns, menu contextuel)
|
||||||
|
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
|
||||||
|
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
|
||||||
|
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
|
||||||
|
- **Changelog bilingue** : page `/changelog` avec historique complet, notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions de code
|
||||||
|
|
||||||
|
### React / TypeScript
|
||||||
|
- Un composant = un fichier `.tsx`, nommé en PascalCase
|
||||||
|
- Hooks custom dans `hooks/`, services dans `services/`
|
||||||
|
- État local via `useReducer` dans les hooks de domaine
|
||||||
|
|
||||||
|
### Rust / Tauri
|
||||||
|
- Toutes les commandes Tauri retournent `Result<T, String>` pour la gestion d'erreurs
|
||||||
|
- Documenter chaque commande avec un commentaire sur son rôle
|
||||||
|
|
||||||
|
### Général
|
||||||
|
- Commits en anglais, commentaires de code en anglais
|
||||||
|
- Messages d'interface en français ET anglais (via i18n)
|
||||||
|
- Tester les cas limites de parsing CSV (montants négatifs, cellules vides, formats inattendus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base de données
|
||||||
|
|
||||||
|
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
||||||
|
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||||
|
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||||
|
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation technique
|
||||||
|
|
||||||
|
La documentation technique est centralisée dans `docs/` :
|
||||||
|
- `docs/architecture.md` — Architecture technique complète (stack, BDD, services, hooks, commandes Tauri, routing, i18n, CI/CD)
|
||||||
|
- `docs/adr/` — Architecture Decision Records (décisions techniques structurantes)
|
||||||
|
- `docs/guide-utilisateur.md` — Guide utilisateur
|
||||||
|
- `docs/archive/` — Anciennes spécifications archivées
|
||||||
|
|
||||||
|
**Règle : quand un changement touche l'architecture, mettre à jour la documentation :**
|
||||||
|
- Nouveau service, hook, commande Tauri, page/route, ou table SQL → mettre à jour `docs/architecture.md`
|
||||||
|
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
|
||||||
|
- Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`)
|
||||||
|
|
||||||
|
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans **les deux fichiers** :
|
||||||
|
- `CHANGELOG.md` (anglais) — source principale
|
||||||
|
- `CHANGELOG.fr.md` (français) — traduction
|
||||||
|
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé
|
||||||
|
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur.
|
||||||
|
- The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points d'attention RS&DE / CRIC
|
||||||
|
|
||||||
|
Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC Québec) :
|
||||||
|
- Documenter les **incertitudes technologiques** rencontrées pendant le développement
|
||||||
|
- Noter les expérimentations et les approches alternatives testées
|
||||||
|
- Garder un journal des avancées techniques (dans `/docs/rnd-journal/`)
|
||||||
|
- Les algorithmes de catégorisation automatique et le parsing multi-format sont des activités R&D éligibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
||||||
|
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
||||||
|
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ressources clés
|
||||||
|
|
||||||
|
- [Tauri v2 Docs](https://v2.tauri.app/)
|
||||||
|
- [React Docs](https://react.dev/)
|
||||||
|
- [SQLite via Tauri](https://github.com/tauri-apps/tauri-plugin-sql)
|
||||||
|
- Architecture détaillée : `docs/architecture.md`
|
||||||
|
- Décisions techniques : `docs/adr/`
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
82
README.md
82
README.md
|
|
@ -1,31 +1,56 @@
|
||||||
# Simpl'Résultat
|
# Simpl'Résultat
|
||||||
|
|
||||||
Application de bureau pour importer, catégoriser et analyser les transactions financières de votre entreprise.
|
Application de bureau 100 % locale pour importer, catégoriser et analyser vos transactions financières personnelles ou d'entreprise. Aucune donnée ne quitte votre ordinateur.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- **Import CSV** — Importez vos relevés bancaires depuis plusieurs sources (Desjardins, etc.)
|
- **Import CSV** — Importez vos relevés bancaires depuis plusieurs sources avec auto-détection des colonnes et modèles d'import réutilisables
|
||||||
- **Tableau de bord** — Vue d'ensemble avec KPIs, répartition par catégorie et dernières dépenses
|
- **Tableau de bord** — Vue d'ensemble avec KPIs, répartition par catégorie et dernières transactions
|
||||||
- **Transactions** — Parcourez, recherchez et filtrez toutes vos transactions
|
- **Transactions** — Parcourez, recherchez et filtrez avec sélection rapide de période
|
||||||
- **Catégorisation automatique** — Attribution automatique par mots-clés, avec ajustement manuel
|
- **Catégorisation automatique** — Attribution automatique par mots-clés, avec ajustement manuel
|
||||||
- **Rapports** — Tendances mensuelles, répartition par catégorie, évolution dans le temps
|
- **Split de transactions** — Répartissez une transaction sur plusieurs catégories
|
||||||
|
- **Budgets** — Grille budgétaire 12 mois par catégorie avec modèles et rapport Budget vs Réel
|
||||||
|
- **Ajustements** — Ajustements ponctuels ou récurrents par catégorie
|
||||||
|
- **Rapports** — Tendances mensuelles, répartition par catégorie, évolution dans le temps, budget vs réel
|
||||||
|
- **Graphiques interactifs** — Motifs SVG, menu contextuel (clic droit), détail des transactions par catégorie
|
||||||
|
- **Profils multiples** — Bases de données séparées avec protection PIN optionnelle
|
||||||
|
- **Export / Import de données** — Sauvegarde complète avec chiffrement AES-256-GCM optionnel
|
||||||
|
- **Mode sombre** — Thème sombre avec palette gris chaud
|
||||||
- **Bilingue** — Interface disponible en français et en anglais
|
- **Bilingue** — Interface disponible en français et en anglais
|
||||||
|
- **Guide utilisateur intégré** — Documentation complète accessible depuis les paramètres, imprimable en PDF
|
||||||
|
- **Mise à jour automatique** — Notifications et installation des nouvelles versions depuis l'application
|
||||||
|
|
||||||
## Installation (Windows)
|
## Installation
|
||||||
|
|
||||||
1. Rendez-vous sur la page [**Releases**](../../releases/latest)
|
### Windows
|
||||||
2. Téléchargez le fichier `.msi` (installateur Windows)
|
|
||||||
|
1. Rendez-vous sur la page [**Releases**](https://github.com/Le-King-Fu/simpl-resultat/releases/latest)
|
||||||
|
2. Téléchargez le fichier `.exe` (installateur NSIS)
|
||||||
3. Lancez le fichier téléchargé
|
3. Lancez le fichier téléchargé
|
||||||
|
|
||||||
> **Note :** Windows SmartScreen peut afficher un avertissement car l'application n'est pas signée numériquement.
|
> **Note :** Windows SmartScreen peut afficher un avertissement car l'application n'est pas signée numériquement.
|
||||||
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »** pour continuer.
|
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »** pour continuer.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
1. Rendez-vous sur la page [**Releases**](https://github.com/Le-King-Fu/simpl-resultat/releases/latest)
|
||||||
|
2. Téléchargez le format adapté à votre distribution :
|
||||||
|
- `.deb` pour Debian / Ubuntu
|
||||||
|
- `.rpm` pour Fedora / openSUSE
|
||||||
|
- `.AppImage` pour toute distribution (exécutable universel)
|
||||||
|
3. Installez le paquet ou lancez l'AppImage directement
|
||||||
|
|
||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
|
|
||||||
### 1. Configurer le dossier d'import
|
### 1. Choisir ou créer un profil
|
||||||
|
|
||||||
|
Au premier lancement, un profil par défaut est créé. Vous pouvez ajouter d'autres profils (chacun avec sa propre base de données) et les protéger par un PIN.
|
||||||
|
|
||||||
|
### 2. Configurer le dossier d'import
|
||||||
|
|
||||||
Organisez vos fichiers CSV dans un dossier avec un sous-dossier par source :
|
Organisez vos fichiers CSV dans un dossier avec un sous-dossier par source :
|
||||||
|
|
||||||
|
|
@ -39,31 +64,43 @@ Documents/
|
||||||
export.csv
|
export.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Importer des transactions
|
### 3. Importer des transactions
|
||||||
|
|
||||||
- Allez dans **Transactions → Importer**
|
- Allez dans **Import**
|
||||||
- Sélectionnez le dossier source et les fichiers CSV
|
- Sélectionnez le dossier source et les fichiers CSV
|
||||||
- Configurez le mappage des colonnes (date, description, montant)
|
- Configurez le mappage des colonnes (ou utilisez un modèle d'import sauvegardé)
|
||||||
- Vérifiez l'aperçu puis lancez l'import
|
- Vérifiez les doublons puis lancez l'import
|
||||||
|
|
||||||
### 3. Consulter le tableau de bord
|
### 4. Consulter le tableau de bord
|
||||||
|
|
||||||
Le tableau de bord affiche automatiquement :
|
Le tableau de bord affiche automatiquement :
|
||||||
- Les KPIs du mois (revenus, dépenses, solde)
|
- Les KPIs du mois (revenus, dépenses, solde)
|
||||||
- La répartition des dépenses par catégorie
|
- La répartition des dépenses par catégorie
|
||||||
- Les dernières transactions
|
- Les dernières transactions
|
||||||
|
|
||||||
### 4. Parcourir et catégoriser les transactions
|
### 5. Parcourir et catégoriser les transactions
|
||||||
|
|
||||||
- Utilisez la recherche et les filtres (date, catégorie, source)
|
- Utilisez la recherche et les filtres (date, catégorie, source, période rapide)
|
||||||
- Modifiez la catégorie d'une transaction en cliquant dessus
|
- Modifiez la catégorie d'une transaction en cliquant dessus
|
||||||
- Ajoutez des mots-clés pour automatiser les futures catégorisations
|
- Ajoutez des mots-clés pour automatiser les futures catégorisations
|
||||||
|
- Scindez une transaction sur plusieurs catégories si nécessaire
|
||||||
|
|
||||||
### 5. Analyser les rapports
|
### 6. Gérer les budgets
|
||||||
|
|
||||||
|
- Définissez un budget mensuel par catégorie sur une grille 12 mois
|
||||||
|
- Créez des modèles budgétaires réutilisables
|
||||||
|
- Consultez le rapport **Budget vs Réel** pour suivre vos écarts
|
||||||
|
|
||||||
|
### 7. Analyser les rapports
|
||||||
|
|
||||||
- **Tendances** — Évolution mensuelle des revenus et dépenses
|
- **Tendances** — Évolution mensuelle des revenus et dépenses
|
||||||
- **Catégories** — Répartition détaillée par catégorie
|
- **Catégories** — Répartition détaillée par catégorie (clic droit pour masquer ou voir le détail)
|
||||||
- **Évolution** — Suivi dans le temps par catégorie
|
- **Évolution** — Suivi dans le temps par catégorie
|
||||||
|
- **Budget vs Réel** — Comparaison mensuelle et cumul annuel
|
||||||
|
|
||||||
|
### 8. Guide utilisateur
|
||||||
|
|
||||||
|
Un guide complet est accessible via **Paramètres → Guide utilisateur**. Il couvre toutes les fonctionnalités et peut être imprimé ou exporté en PDF.
|
||||||
|
|
||||||
## Développement
|
## Développement
|
||||||
|
|
||||||
|
|
@ -95,11 +132,11 @@ Les installateurs sont générés dans `src-tauri/target/release/bundle/`.
|
||||||
3. Créez et poussez un tag :
|
3. Créez et poussez un tag :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v0.1.0
|
git tag v0.3.7
|
||||||
git push origin v0.1.0
|
git push origin v0.3.7
|
||||||
```
|
```
|
||||||
|
|
||||||
Le workflow GitHub Actions compile automatiquement l'application et publie les installateurs dans une nouvelle Release.
|
Le workflow GitHub Actions compile automatiquement l'application pour Windows et Linux, puis publie les installateurs dans une nouvelle Release.
|
||||||
|
|
||||||
## Technologies
|
## Technologies
|
||||||
|
|
||||||
|
|
@ -111,3 +148,4 @@ Le workflow GitHub Actions compile automatiquement l'application et publie les i
|
||||||
| [Tailwind CSS v4](https://tailwindcss.com/) | Styles |
|
| [Tailwind CSS v4](https://tailwindcss.com/) | Styles |
|
||||||
| [Recharts](https://recharts.org/) | Graphiques |
|
| [Recharts](https://recharts.org/) | Graphiques |
|
||||||
| [react-i18next](https://react.i18next.com/) | Internationalisation |
|
| [react-i18next](https://react.i18next.com/) | Internationalisation |
|
||||||
|
| [PapaParse](https://www.papaparse.com/) | Parsing CSV |
|
||||||
|
|
|
||||||
33
docs/adr/0001-tauri-v2.md
Normal file
33
docs/adr/0001-tauri-v2.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# ADR-0001 : Choix de Tauri v2 plutôt qu'Electron
|
||||||
|
|
||||||
|
- **Date** : 2025-01
|
||||||
|
- **Statut** : Accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Simpl'Résultat est une application de gestion financière personnelle qui doit fonctionner en tant qu'application desktop native sur Windows et Linux. Le choix du framework desktop impacte directement la taille de l'installeur, la consommation mémoire, la sécurité et l'accès aux APIs système.
|
||||||
|
|
||||||
|
Les deux options principales étaient :
|
||||||
|
- **Electron** : mature, large écosystème, embarque Chromium + Node.js
|
||||||
|
- **Tauri v2** : plus récent, utilise le webview natif du système, backend Rust
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
Nous avons choisi **Tauri v2** comme framework desktop.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Taille de l'installeur** réduite (~10 Mo vs ~150 Mo pour Electron)
|
||||||
|
- **Consommation mémoire** significativement plus faible (pas de Chromium embarqué)
|
||||||
|
- **Sécurité** renforcée : backend Rust (memory-safe), système de capabilities granulaire de Tauri v2
|
||||||
|
- **Accès natif** au système de fichiers, dialogues natifs, et SQLite via les plugins Tauri
|
||||||
|
- **Chiffrement performant** : AES-256-GCM et Argon2 implémentés nativement en Rust
|
||||||
|
|
||||||
|
### Négatives
|
||||||
|
|
||||||
|
- **Écosystème** moins mature qu'Electron (moins de plugins communautaires)
|
||||||
|
- **Différences de rendu** potentielles entre les webviews (WebView2 sur Windows, WebKitGTK sur Linux)
|
||||||
|
- **Courbe d'apprentissage** Rust pour l'équipe frontend
|
||||||
|
- **Tauri v2** était encore récent au moment du choix, avec moins de retours d'expérience
|
||||||
38
docs/adr/0002-useReducer-vs-redux.md
Normal file
38
docs/adr/0002-useReducer-vs-redux.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ADR-0002 : useReducer plutôt que Redux/Zustand
|
||||||
|
|
||||||
|
- **Date** : 2025-01
|
||||||
|
- **Statut** : Accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'application nécessite une gestion d'état complexe pour chaque domaine métier (transactions, catégories, budget, import, etc.). Chaque domaine a son propre cycle de vie avec des opérations CRUD, du chargement asynchrone, et des états d'erreur.
|
||||||
|
|
||||||
|
Les options considérées :
|
||||||
|
- **Redux** (+ Redux Toolkit) : standard de l'industrie, middleware, DevTools
|
||||||
|
- **Zustand** : léger, API simple, pas de boilerplate
|
||||||
|
- **useReducer** (React natif) : intégré à React, pas de dépendance externe
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
Nous avons choisi **useReducer** (React natif) avec un hook custom par domaine métier, soit 12 hooks au total.
|
||||||
|
|
||||||
|
Chaque hook encapsule :
|
||||||
|
- Un reducer avec des actions typées
|
||||||
|
- Les appels aux services (couche d'accès aux données)
|
||||||
|
- L'état de chargement et la gestion d'erreur
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Zéro dépendance** supplémentaire pour la gestion d'état
|
||||||
|
- **Colocalisation** : chaque domaine est isolé dans son propre hook, ce qui facilite la maintenance
|
||||||
|
- **Typage TypeScript** natif des actions et de l'état, sans configuration additionnelle
|
||||||
|
- **Simplicité** : pas de store global, pas de middleware, pas de boilerplate Redux
|
||||||
|
- **Prévisibilité** : le pattern reducer garde la logique de transition d'état explicite
|
||||||
|
|
||||||
|
### Négatives
|
||||||
|
|
||||||
|
- **Pas de DevTools** intégrés (pas d'inspection de l'historique des actions comme Redux DevTools)
|
||||||
|
- **Pas de partage d'état** natif entre hooks (résolu par le contexte `ProfileContext` pour l'état global)
|
||||||
|
- **Duplication** potentielle de patterns similaires entre les 12 hooks (chargement, erreur, CRUD)
|
||||||
42
docs/adr/0003-sqlx-migrations.md
Normal file
42
docs/adr/0003-sqlx-migrations.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# ADR-0003 : Migrations sqlx inline et contrainte de checksum
|
||||||
|
|
||||||
|
- **Date** : 2025-02
|
||||||
|
- **Statut** : Accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'application utilise SQLite via `tauri-plugin-sql` pour le stockage local. Le schéma de la base de données évolue à chaque version (ajout de colonnes, nouvelles tables, changement de contraintes). Il faut un système de migration fiable qui :
|
||||||
|
- Applique les migrations dans l'ordre au démarrage
|
||||||
|
- Détecte les modifications non autorisées du schéma
|
||||||
|
- Supporte la création de nouvelles bases (nouveaux profils)
|
||||||
|
|
||||||
|
Le plugin `tauri-plugin-sql` fournit un système de migrations intégré basé sur `sqlx::migrate`, avec vérification de checksum.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
Nous utilisons les **migrations inline** définies dans `lib.rs` via `tauri_plugin_sql::Migration`. Chaque migration est une chaîne SQL embarquée dans le binaire.
|
||||||
|
|
||||||
|
En complément, un fichier `consolidated_schema.sql` contient le schéma complet (toutes les migrations fusionnées) et est utilisé pour initialiser les nouveaux profils sans rejouer les 6 migrations.
|
||||||
|
|
||||||
|
Les 6 migrations actuelles :
|
||||||
|
1. Schéma initial (13 tables, 9 index)
|
||||||
|
2. Seed des catégories et mots-clés par défaut
|
||||||
|
3. Ajout `has_header` sur `import_sources`
|
||||||
|
4. Ajout `is_inputable` sur `categories`
|
||||||
|
5. Création de `import_config_templates`
|
||||||
|
6. Changement de contrainte unique sur `imported_files`
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Vérification de checksum** : sqlx détecte toute modification d'une migration déjà appliquée, empêchant les corruptions silencieuses
|
||||||
|
- **Migrations embarquées** dans le binaire : pas de fichiers SQL à distribuer séparément
|
||||||
|
- **Schéma consolidé** : les nouveaux profils démarrent avec un schéma propre sans passer par l'historique des migrations
|
||||||
|
- **Traçabilité** : chaque changement de schéma est versionné et documenté
|
||||||
|
|
||||||
|
### Négatives
|
||||||
|
|
||||||
|
- **Immutabilité** : une migration appliquée ne peut jamais être modifiée (erreur de checksum), ce qui force la création de nouvelles migrations correctives
|
||||||
|
- **Synchronisation manuelle** : le fichier `consolidated_schema.sql` doit être mis à jour manuellement après chaque nouvelle migration
|
||||||
|
- **Pas de rollback** : les migrations sont unidirectionnelles (pas de `down`)
|
||||||
43
docs/adr/0004-aes-256-gcm-encryption.md
Normal file
43
docs/adr/0004-aes-256-gcm-encryption.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# ADR-0004 : Chiffrement AES-256-GCM pour l'export de données
|
||||||
|
|
||||||
|
- **Date** : 2025-06
|
||||||
|
- **Statut** : Accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'application permet d'exporter et d'importer des données utilisateur (transactions, catégories, budgets) pour la sauvegarde ou le transfert entre machines. Ces données contiennent des informations financières personnelles et doivent être protégées.
|
||||||
|
|
||||||
|
Il fallait un format d'export :
|
||||||
|
- Chiffré avec un mot de passe utilisateur
|
||||||
|
- Intègre (détection de toute altération)
|
||||||
|
- Auto-contenu (un seul fichier)
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
Nous avons implémenté un **format propriétaire SREF** (Simpl'Résultat Export Format) côté Rust avec :
|
||||||
|
|
||||||
|
- **Chiffrement** : AES-256-GCM (chiffrement authentifié)
|
||||||
|
- **Dérivation de clé** : Argon2id à partir du mot de passe utilisateur
|
||||||
|
- **Format binaire** :
|
||||||
|
- Magic : `SREF` (4 octets)
|
||||||
|
- Version : `0x01` (1 octet)
|
||||||
|
- Salt : 16 octets (aléatoire)
|
||||||
|
- Nonce : 12 octets (aléatoire)
|
||||||
|
- Données chiffrées : reste du fichier
|
||||||
|
|
||||||
|
La détection du format se fait via la commande `is_file_encrypted` qui vérifie le magic `SREF`.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Chiffrement authentifié** : AES-256-GCM garantit à la fois la confidentialité et l'intégrité des données
|
||||||
|
- **Résistance au brute-force** : Argon2id rend les attaques par dictionnaire coûteuses
|
||||||
|
- **Implémentation Rust** : performances natives, pas de dépendance à des binaires externes
|
||||||
|
- **Format auto-détectable** : le magic `SREF` permet de distinguer fichiers chiffrés et non chiffrés
|
||||||
|
|
||||||
|
### Négatives
|
||||||
|
|
||||||
|
- **Format propriétaire** : pas d'interopérabilité avec d'autres outils (pas de standard comme GPG)
|
||||||
|
- **Pas de récupération** possible si le mot de passe est perdu
|
||||||
|
- **Évolution du format** : les changements de version nécessitent une rétrocompatibilité
|
||||||
40
docs/adr/0005-multi-profile-db.md
Normal file
40
docs/adr/0005-multi-profile-db.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# ADR-0005 : Architecture multi-profils avec bases de données séparées
|
||||||
|
|
||||||
|
- **Date** : 2025-08
|
||||||
|
- **Statut** : Accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'application doit supporter plusieurs profils utilisateur (par exemple, un profil personnel et un profil professionnel) avec une isolation complète des données. Chaque profil a ses propres catégories, transactions, budgets et paramètres.
|
||||||
|
|
||||||
|
Les options considérées :
|
||||||
|
- **Base unique avec colonne `profile_id`** : simple, mais risque de fuite de données entre profils
|
||||||
|
- **Bases de données séparées** : isolation totale, mais gestion plus complexe
|
||||||
|
- **Schémas SQLite** (ATTACH DATABASE) : isolation partielle, API complexe
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
Chaque profil possède sa **propre base de données SQLite**. La liste des profils est stockée dans un fichier `profiles.json` dans le répertoire de données de l'application.
|
||||||
|
|
||||||
|
Architecture :
|
||||||
|
- `profiles.json` : métadonnées des profils (nom, chemin de la BDD, hash du PIN)
|
||||||
|
- `ProfileContext` (React) : état global du profil actif, liste des profils, fonctions de switching
|
||||||
|
- `ProfileSelectionPage` : page affichée quand aucun profil n'est actif (gate)
|
||||||
|
- Les nouveaux profils sont initialisés avec `consolidated_schema.sql` (via la commande `get_new_profile_init_sql`)
|
||||||
|
- Chaque profil peut être protégé par un PIN (hashé avec Argon2)
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Isolation totale** : aucun risque de fuite de données entre profils
|
||||||
|
- **Suppression simple** : supprimer un profil = supprimer un fichier `.db`
|
||||||
|
- **Sauvegarde indépendante** : chaque profil peut être exporté/importé séparément
|
||||||
|
- **Pas de migration croisée** : les migrations s'appliquent indépendamment à chaque base
|
||||||
|
|
||||||
|
### Négatives
|
||||||
|
|
||||||
|
- **Reconnexion nécessaire** : le changement de profil nécessite la fermeture et la réouverture de la connexion SQLite
|
||||||
|
- **Pas de vue agrégée** : impossible de voir les données de tous les profils en même temps
|
||||||
|
- **Synchronisation du schéma** : chaque base doit être au même niveau de migration (géré par le plugin sql au démarrage)
|
||||||
|
- **Fichier `profiles.json` externe** : les métadonnées des profils ne sont pas dans SQLite, ce qui crée une dépendance à un fichier JSON séparé
|
||||||
224
docs/architecture.md
Normal file
224
docs/architecture.md
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
# Architecture technique — Simpl'Résultat
|
||||||
|
|
||||||
|
> Document mis à jour le 2026-03-07 — Version 0.6.3
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologie | Version |
|
||||||
|
|--------|------------|---------|
|
||||||
|
| Framework desktop | Tauri | v2 |
|
||||||
|
| Frontend | React | 19.1 |
|
||||||
|
| Langage frontend | TypeScript | 5.8 |
|
||||||
|
| Bundler | Vite | 6.4 |
|
||||||
|
| CSS | Tailwind CSS | v4 |
|
||||||
|
| Backend | Rust (via Tauri) | stable |
|
||||||
|
| Base de données | SQLite (tauri-plugin-sql) | — |
|
||||||
|
| Graphiques | Recharts | 3.7 |
|
||||||
|
| Icônes | Lucide React | 0.563 |
|
||||||
|
| i18n | i18next + react-i18next | 25.8 / 16.5 |
|
||||||
|
| Drag & Drop | @dnd-kit | 6.3 / 10.0 |
|
||||||
|
| CSV | PapaParse | 5.5 |
|
||||||
|
| Chiffrement | aes-gcm (Rust) | 0.10 |
|
||||||
|
| Hachage PIN | Argon2 (Rust) | 0.5 |
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
simpl-resultat/
|
||||||
|
├── src/ # Frontend React/TypeScript
|
||||||
|
│ ├── components/ # 55 composants organisés par domaine
|
||||||
|
│ │ ├── adjustments/ # 3 composants
|
||||||
|
│ │ ├── budget/ # 5 composants
|
||||||
|
│ │ ├── categories/ # 5 composants
|
||||||
|
│ │ ├── dashboard/ # 2 composants
|
||||||
|
│ │ ├── import/ # 13 composants (wizard d'import)
|
||||||
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
|
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
|
||||||
|
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
|
||||||
|
│ │ ├── shared/ # 6 composants réutilisables
|
||||||
|
│ │ └── transactions/ # 5 composants
|
||||||
|
│ ├── contexts/ # ProfileContext (état global profil)
|
||||||
|
│ ├── hooks/ # 12 hooks custom (useReducer)
|
||||||
|
│ ├── pages/ # 10 pages
|
||||||
|
│ ├── services/ # 14 services métier
|
||||||
|
│ ├── shared/ # Types et constantes partagés
|
||||||
|
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
|
||||||
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
|
│ ├── App.tsx # Router principal
|
||||||
|
│ └── main.tsx # Point d'entrée
|
||||||
|
├── src-tauri/ # Backend Rust
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── commands/ # 3 modules de commandes Tauri
|
||||||
|
│ │ │ ├── fs_commands.rs
|
||||||
|
│ │ │ ├── export_import_commands.rs
|
||||||
|
│ │ │ └── profile_commands.rs
|
||||||
|
│ │ ├── database/ # Schémas SQL et migrations
|
||||||
|
│ │ │ ├── schema.sql
|
||||||
|
│ │ │ ├── seed_categories.sql
|
||||||
|
│ │ │ └── consolidated_schema.sql
|
||||||
|
│ │ ├── lib.rs # Point d'entrée, migrations, plugins
|
||||||
|
│ │ └── main.rs
|
||||||
|
│ ├── capabilities/ # Permissions Tauri
|
||||||
|
│ └── Cargo.toml
|
||||||
|
├── .github/workflows/ # CI/CD
|
||||||
|
│ └── release.yml
|
||||||
|
├── docs/ # Documentation technique
|
||||||
|
└── config/ # Configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base de données
|
||||||
|
|
||||||
|
### Tables (13)
|
||||||
|
|
||||||
|
| Table | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `import_sources` | Configuration des sources d'import CSV |
|
||||||
|
| `imported_files` | Suivi des fichiers importés (hash anti-doublons) |
|
||||||
|
| `categories` | Catégories hiérarchiques (dépenses/revenus) |
|
||||||
|
| `suppliers` | Fournisseurs avec auto-catégorisation |
|
||||||
|
| `keywords` | Mots-clés pour catégorisation automatique |
|
||||||
|
| `transactions` | Transactions individuelles |
|
||||||
|
| `adjustments` | Ajustements manuels (ponctuels ou récurrents) |
|
||||||
|
| `adjustment_entries` | Montants par catégorie pour chaque ajustement |
|
||||||
|
| `budget_entries` | Allocations budgétaires mensuelles par catégorie |
|
||||||
|
| `budget_templates` | Modèles de budget réutilisables |
|
||||||
|
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||||
|
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||||
|
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
||||||
|
|
||||||
|
### Index (9)
|
||||||
|
|
||||||
|
Index sur : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
|
||||||
|
|
||||||
|
## Système de migrations
|
||||||
|
|
||||||
|
Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` :
|
||||||
|
|
||||||
|
| # | Version | Description |
|
||||||
|
|---|---------|-------------|
|
||||||
|
| 1 | v1 | Schéma initial (13 tables) |
|
||||||
|
| 2 | v2 | Seed des catégories et mots-clés |
|
||||||
|
| 3 | v3 | Ajout `has_header` sur `import_sources` |
|
||||||
|
| 4 | v4 | Ajout `is_inputable` sur `categories` |
|
||||||
|
| 5 | v5 | Création de `import_config_templates` |
|
||||||
|
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
|
||||||
|
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
||||||
|
|
||||||
|
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
||||||
|
|
||||||
|
## Services TypeScript (15)
|
||||||
|
|
||||||
|
| Service | Responsabilité |
|
||||||
|
|---------|---------------|
|
||||||
|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
|
||||||
|
| `profileService.ts` | Gestion des profils |
|
||||||
|
| `categoryService.ts` | CRUD catégories hiérarchiques |
|
||||||
|
| `transactionService.ts` | CRUD et filtrage des transactions |
|
||||||
|
| `importSourceService.ts` | Configuration des sources d'import |
|
||||||
|
| `importedFileService.ts` | Suivi des fichiers importés |
|
||||||
|
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
|
||||||
|
| `categorizationService.ts` | Catégorisation automatique |
|
||||||
|
| `adjustmentService.ts` | Gestion des ajustements |
|
||||||
|
| `budgetService.ts` | Gestion budgétaire |
|
||||||
|
| `dashboardService.ts` | Agrégation données tableau de bord |
|
||||||
|
| `reportService.ts` | Génération de rapports et analytique |
|
||||||
|
| `dataExportService.ts` | Export de données (chiffré) |
|
||||||
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
|
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||||
|
|
||||||
|
## Hooks (12)
|
||||||
|
|
||||||
|
Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
|
|
||||||
|
| Hook | Domaine |
|
||||||
|
|------|---------|
|
||||||
|
| `useCategories` | Catégories avec hiérarchie |
|
||||||
|
| `useTransactions` | Transactions et filtrage |
|
||||||
|
| `useDataImport` | Import de données |
|
||||||
|
| `useImportWizard` | Assistant d'import multi-étapes |
|
||||||
|
| `useImportHistory` | Historique des imports |
|
||||||
|
| `useAdjustments` | Ajustements |
|
||||||
|
| `useBudget` | Budget |
|
||||||
|
| `useDashboard` | Métriques du tableau de bord |
|
||||||
|
| `useReports` | Données analytiques |
|
||||||
|
| `useDataExport` | Export de données |
|
||||||
|
| `useTheme` | Thème clair/sombre |
|
||||||
|
| `useUpdater` | Mise à jour de l'application |
|
||||||
|
|
||||||
|
## Commandes Tauri (18)
|
||||||
|
|
||||||
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
||||||
|
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
|
||||||
|
- `read_file_content` — Lecture avec gestion de l'encodage
|
||||||
|
- `hash_file` — Hash SHA-256 (détection de doublons)
|
||||||
|
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
|
||||||
|
- `get_file_preview` — Aperçu des N premières lignes
|
||||||
|
- `pick_folder` — Dialogue de sélection de dossier
|
||||||
|
|
||||||
|
### `export_import_commands.rs` — Export/Import de données (5)
|
||||||
|
|
||||||
|
- `pick_save_file` — Dialogue de sauvegarde
|
||||||
|
- `pick_import_file` — Dialogue de sélection de fichier
|
||||||
|
- `write_export_file` — Écriture fichier chiffré (format SREF)
|
||||||
|
- `read_import_file` — Lecture fichier chiffré
|
||||||
|
- `is_file_encrypted` — Vérification magic SREF
|
||||||
|
|
||||||
|
### `profile_commands.rs` — Gestion des profils (7)
|
||||||
|
|
||||||
|
- `load_profiles` — Chargement depuis `profiles.json`
|
||||||
|
- `save_profiles` — Sauvegarde de la configuration
|
||||||
|
- `delete_profile_db` — Suppression du fichier de base de données
|
||||||
|
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||||
|
- `hash_pin` — Hachage Argon2 du PIN
|
||||||
|
- `verify_pin` — Vérification du PIN
|
||||||
|
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||||
|
|
||||||
|
## Pages et routing
|
||||||
|
|
||||||
|
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
||||||
|
|
||||||
|
### Gestion d'erreurs
|
||||||
|
|
||||||
|
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||||
|
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
|
||||||
|
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
|
||||||
|
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
|
||||||
|
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
|
||||||
|
- **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard`
|
||||||
|
|
||||||
|
| Route | Page | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
||||||
|
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||||
|
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||||
|
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||||
|
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
|
||||||
|
| `/budget` | `BudgetPage` | Planification budgétaire |
|
||||||
|
| `/reports` | `ReportsPage` | Analytique et rapports |
|
||||||
|
| `/settings` | `SettingsPage` | Paramètres |
|
||||||
|
| `/docs` | `DocsPage` | Documentation in-app |
|
||||||
|
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
||||||
|
|
||||||
|
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
|
||||||
|
|
||||||
|
## Internationalisation
|
||||||
|
|
||||||
|
- **Librairie** : i18next + react-i18next
|
||||||
|
- **Langue par défaut** : Français (`fr`)
|
||||||
|
- **Langue de fallback** : Anglais (`en`)
|
||||||
|
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
|
||||||
|
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Workflow GitHub Actions (`release.yml`) déclenché par les tags `v*` :
|
||||||
|
|
||||||
|
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
|
||||||
|
2. **build-linux** (ubuntu-22.04) → `.deb` + `.AppImage`
|
||||||
|
|
||||||
|
Fonctionnalités :
|
||||||
|
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
||||||
|
- JSON d'updater pour mises à jour automatiques
|
||||||
|
- Release GitHub automatique avec notes d'installation
|
||||||
327
docs/guide-utilisateur.md
Normal file
327
docs/guide-utilisateur.md
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# Guide d'utilisation — Simpl'Résultat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Premiers pas
|
||||||
|
|
||||||
|
Simpl'Résultat vous aide à suivre vos finances personnelles en important des relevés bancaires, en catégorisant les transactions, en planifiant des budgets et en générant des rapports. L'application est disponible sur Windows et Linux.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Importation de relevés bancaires CSV depuis plusieurs sources
|
||||||
|
- Catégorisation automatique et manuelle des transactions
|
||||||
|
- Répartition (split) d'une transaction sur plusieurs catégories
|
||||||
|
- Planification budgétaire avec vues mensuelles et annuelles
|
||||||
|
- Rapports visuels et graphiques interactifs avec menu contextuel
|
||||||
|
- Profils multiples avec bases de données séparées et NIP optionnel
|
||||||
|
- Mode sombre avec palette gris chaud
|
||||||
|
- Export et import de données avec chiffrement AES-256 optionnel
|
||||||
|
|
||||||
|
### Démarrage rapide
|
||||||
|
|
||||||
|
1. Au premier lancement, choisissez ou créez un profil — chaque profil a sa propre base de données
|
||||||
|
2. Allez dans Paramètres et définissez votre dossier d'import — créez un sous-dossier par compte bancaire
|
||||||
|
3. Placez vos relevés bancaires CSV dans le sous-dossier correspondant
|
||||||
|
4. Ouvrez la page Import et configurez votre source (mapping des colonnes, délimiteur, format de date)
|
||||||
|
5. Importez vos transactions, puis allez dans Catégories pour configurer les règles de mots-clés
|
||||||
|
6. Utilisez l'auto-catégorisation sur la page Transactions pour appliquer vos règles en masse
|
||||||
|
7. Configurez votre Budget et suivez votre progression via les Rapports
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale
|
||||||
|
- Activez le mode sombre via le bouton dans la barre latérale
|
||||||
|
- Chaque page a une icône d'aide (?) dans l'en-tête avec des astuces rapides
|
||||||
|
- Vos données sont stockées localement sur votre ordinateur — rien n'est envoyé vers le cloud
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Profils
|
||||||
|
|
||||||
|
Gérez plusieurs profils indépendants, chacun avec sa propre base de données. Idéal pour séparer les finances personnelles et professionnelles, ou pour plusieurs utilisateurs sur un même ordinateur.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Création de profils multiples avec noms et couleurs personnalisés
|
||||||
|
- Chaque profil possède sa propre base de données séparée
|
||||||
|
- Protection optionnelle par NIP (code numérique)
|
||||||
|
- Changement de profil rapide depuis la barre latérale
|
||||||
|
- Suppression de profil avec toutes ses données
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Cliquez sur le sélecteur de profil dans la barre latérale pour voir les profils disponibles
|
||||||
|
2. Cliquez sur Gérer les profils pour créer, modifier ou supprimer des profils
|
||||||
|
3. Créez un nouveau profil en choisissant un nom, une couleur et un NIP optionnel
|
||||||
|
4. Basculez entre les profils en cliquant sur celui de votre choix dans le sélecteur
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Un profil par défaut est créé automatiquement au premier lancement
|
||||||
|
- Le NIP est demandé à chaque fois que vous accédez à un profil protégé
|
||||||
|
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tableau de bord
|
||||||
|
|
||||||
|
Le tableau de bord vous donne un aperçu rapide de votre situation financière pour une période sélectionnée.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Cartes résumées du solde, des revenus et des dépenses
|
||||||
|
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
|
||||||
|
- Tableau Budget vs Réel du mois courant (écart en $ et %)
|
||||||
|
- Histogramme empilé des dépenses par catégorie et par mois
|
||||||
|
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
||||||
|
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
||||||
|
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
||||||
|
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
||||||
|
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
|
||||||
|
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
|
||||||
|
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture
|
||||||
|
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
|
||||||
|
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
|
||||||
|
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Import
|
||||||
|
|
||||||
|
Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assistant étape par étape. Chaque compte bancaire est représenté par un dossier source.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Assistant d'import multi-étapes avec aperçu des données
|
||||||
|
- Mapping de colonnes configurable, délimiteur et format de date
|
||||||
|
- Détection automatique des doublons (dans le lot et contre les données existantes)
|
||||||
|
- Modèles d'import pour sauvegarder et réutiliser les configurations
|
||||||
|
- Historique des imports avec possibilité de supprimer les imports précédents
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
|
||||||
|
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV
|
||||||
|
3. Cliquez sur une source pour ouvrir l'assistant d'import
|
||||||
|
4. Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes
|
||||||
|
5. Sélectionnez les fichiers à importer et prévisualisez les données analysées
|
||||||
|
6. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois
|
||||||
|
- Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons
|
||||||
|
- Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Transactions
|
||||||
|
|
||||||
|
Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'est ici que vous organisez vos données financières.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Recherche et filtre par description, catégorie, source ou plage de dates
|
||||||
|
- Sélecteurs de période rapides (ce mois, mois dernier, cette année, etc.)
|
||||||
|
- Colonnes triables (date, description, montant, catégorie)
|
||||||
|
- Assignation de catégorie en ligne via menu déroulant
|
||||||
|
- Auto-catégorisation basée sur les règles de mots-clés
|
||||||
|
- Ajout de mots-clés directement depuis une transaction
|
||||||
|
- Répartition (split) d'une transaction sur plusieurs catégories
|
||||||
|
- Notes sur les transactions
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Utilisez la barre de filtres pour affiner les transactions par texte, catégorie, source ou date
|
||||||
|
2. Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant
|
||||||
|
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
|
||||||
|
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
|
||||||
|
5. Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé
|
||||||
|
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
|
||||||
|
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
|
||||||
|
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
|
||||||
|
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Catégories
|
||||||
|
|
||||||
|
Gérez votre arbre de catégories avec des sous-catégories, des règles de mots-clés pour l'auto-catégorisation et des couleurs personnalisées.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Catégories hiérarchiques avec relations parent/enfant
|
||||||
|
- Trois types de catégories : Dépense, Revenu, Transfert
|
||||||
|
- Règles de mots-clés avec niveaux de priorité pour l'auto-catégorisation
|
||||||
|
- Couleurs personnalisées pour l'affichage des graphiques
|
||||||
|
- Glisser-déposer pour réordonner les catégories ou changer leur parent
|
||||||
|
- Basculer les catégories en saisissable ou non-saisissable
|
||||||
|
- Vue « Tous les mots-clés » pour voir l'ensemble des règles
|
||||||
|
- Réinitialiser les catégories aux valeurs par défaut
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel
|
||||||
|
2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés
|
||||||
|
3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent
|
||||||
|
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
|
||||||
|
5. Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent
|
||||||
|
6. Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports
|
||||||
|
- Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction
|
||||||
|
- Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation
|
||||||
|
- Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ajustements
|
||||||
|
|
||||||
|
Ajoutez des entrées manuelles non issues de vos relevés bancaires, et consultez les répartitions de transactions créées depuis la page Transactions.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Créer des groupes d'ajustement nommés avec plusieurs entrées
|
||||||
|
- Assigner une catégorie à chaque entrée
|
||||||
|
- Marquer des ajustements comme récurrents
|
||||||
|
- Consultation des répartitions (splits) de transactions dans une section dédiée
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement
|
||||||
|
2. Ajoutez des entrées avec une description, un montant, une date et une catégorie
|
||||||
|
3. Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période
|
||||||
|
4. Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Les ajustements apparaissent dans vos réels de budget aux côtés des transactions importées
|
||||||
|
- Utilisez les ajustements pour les dépenses prévues qui n'ont pas encore été débitées de votre compte
|
||||||
|
- Les répartitions de transactions sont créées depuis la page Transactions et apparaissent automatiquement ici
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Budget
|
||||||
|
|
||||||
|
Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par rapport au réel tout au long de l'année.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Grille budgétaire mensuelle pour toutes les catégories
|
||||||
|
- Colonne annuelle avec totaux automatiques
|
||||||
|
- Répartition égale du montant annuel sur 12 mois
|
||||||
|
- Modèles de budget pour sauvegarder et appliquer des configurations
|
||||||
|
- Sous-totaux par catégorie parente
|
||||||
|
- En-têtes de colonnes fixes au défilement vertical
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Utilisez le navigateur d'année pour sélectionner l'année du budget
|
||||||
|
2. Cliquez sur une cellule de mois pour entrer un montant prévu
|
||||||
|
3. Appuyez sur Entrée pour sauvegarder, Échap pour annuler, ou Tab pour passer au mois suivant
|
||||||
|
4. Utilisez le bouton de répartition (sur la colonne Annuel) pour distribuer également sur tous les mois
|
||||||
|
5. Sauvegardez votre budget comme modèle pour le réutiliser les années suivantes
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas
|
||||||
|
- Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup
|
||||||
|
- Les catégories parentes affichent les sous-totaux agrégés de leurs enfants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Rapports
|
||||||
|
|
||||||
|
Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
|
||||||
|
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
|
||||||
|
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
|
||||||
|
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
|
||||||
|
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
||||||
|
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||||
|
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||||
|
- Détail des transactions par catégorie avec tri par colonne (date, description, montant)
|
||||||
|
- Toggle pour afficher ou masquer les montants dans le détail des transactions
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
|
||||||
|
2. Ajustez la période avec le sélecteur de période
|
||||||
|
3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||||
|
4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
|
||||||
|
5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
|
||||||
|
6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
|
||||||
|
7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
|
||||||
|
- Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
|
||||||
|
- Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
|
||||||
|
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
|
||||||
|
|
||||||
|
### Rapport dynamique
|
||||||
|
|
||||||
|
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
|
||||||
|
|
||||||
|
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
|
||||||
|
|
||||||
|
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
|
||||||
|
|
||||||
|
1. Cliquez sur un champ disponible dans le panneau de droite
|
||||||
|
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
|
||||||
|
3. Le tableau et/ou le graphique se mettent à jour automatiquement
|
||||||
|
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
|
||||||
|
5. Basculez entre les vues Tableau, Graphique ou Les deux
|
||||||
|
6. Cliquez sur le X pour retirer un champ d'une zone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Paramètres
|
||||||
|
|
||||||
|
Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Affichage de la version de l'application
|
||||||
|
- Guide d'utilisation complet accessible directement depuis les paramètres
|
||||||
|
- Vérification automatique des mises à jour avec installation en un clic
|
||||||
|
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
|
||||||
|
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
|
||||||
|
- Import des données depuis un fichier exporté précédemment
|
||||||
|
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
|
||||||
|
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
|
||||||
|
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
|
||||||
|
4. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||||
|
5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||||
|
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée
|
||||||
|
- Changez la langue de l'application via le sélecteur de langue dans la barre latérale
|
||||||
|
- Exportez régulièrement pour garder une sauvegarde de vos données
|
||||||
|
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
|
||||||
|
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
|
||||||
|
- En cas de problème, copiez les journaux et joignez-les à votre signalement
|
||||||
577
package-lock.json
generated
577
package-lock.json
generated
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.3.0",
|
"version": "0.6.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.3.0",
|
"version": "0.6.5",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|
@ -34,7 +35,8 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1"
|
"vite": "^6.4.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|
@ -859,325 +861,350 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -1737,6 +1764,17 @@
|
||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
|
@ -1791,6 +1829,13 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -1858,6 +1903,127 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"chai": "^6.2.1",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
|
|
@ -1920,6 +2086,16 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
|
@ -2112,6 +2288,13 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.44.0",
|
"version": "1.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
|
@ -2167,11 +2350,31 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
|
@ -2617,11 +2820,29 @@
|
||||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/papaparse": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
|
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -2831,10 +3052,11 @@
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -2846,31 +3068,31 @@
|
||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2893,6 +3115,13 @@
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
@ -2902,6 +3131,20 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
|
@ -2926,6 +3169,23 @@
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -2942,6 +3202,16 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
@ -3099,6 +3369,84 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.0.18",
|
||||||
|
"@vitest/mocker": "4.0.18",
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"@vitest/runner": "4.0.18",
|
||||||
|
"@vitest/snapshot": "4.0.18",
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"expect-type": "^1.2.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^3.10.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.0.3",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.0.18",
|
||||||
|
"@vitest/browser-preview": "4.0.18",
|
||||||
|
"@vitest/browser-webdriverio": "4.0.18",
|
||||||
|
"@vitest/ui": "4.0.18",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
|
@ -3107,6 +3455,23 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -1,13 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.4",
|
"version": "0.6.6",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -36,6 +39,7 @@
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.4.1"
|
"vite": "^6.4.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.3.4"
|
version = "0.6.6"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -24,6 +25,8 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256, Sha384};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
|
@ -155,3 +155,65 @@ pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
||||||
fn hex_encode(bytes: &[u8]) -> String {
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repair migration checksums for a profile database.
|
||||||
|
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
||||||
|
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
|
||||||
|
let app_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||||
|
let db_path = app_dir.join(&db_filename);
|
||||||
|
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = rusqlite::Connection::open(&db_path)
|
||||||
|
.map_err(|e| format!("Cannot open database: {}", e))?;
|
||||||
|
|
||||||
|
let table_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !table_exists {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current migration SQL — must match the vec in lib.rs
|
||||||
|
let migrations: &[(i64, &str)] = &[
|
||||||
|
(1, database::SCHEMA),
|
||||||
|
(2, database::SEED_CATEGORIES),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut repaired = false;
|
||||||
|
for (version, sql) in migrations {
|
||||||
|
let expected_checksum = Sha384::digest(sql.as_bytes()).to_vec();
|
||||||
|
|
||||||
|
// Check if this migration exists with a different checksum
|
||||||
|
let needs_repair: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM _sqlx_migrations WHERE version = ?1 AND checksum != ?2",
|
||||||
|
rusqlite::params![version, expected_checksum],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if needs_repair {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2",
|
||||||
|
rusqlite::params![expected_checksum, version],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Cannot repair migration {}: {}", version, e))?;
|
||||||
|
repaired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(repaired)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,18 @@ pub fn run() {
|
||||||
ALTER TABLE imported_files_new RENAME TO imported_files;",
|
ALTER TABLE imported_files_new RENAME TO imported_files;",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 7,
|
||||||
|
description: "add level-3 insurance subcategories",
|
||||||
|
sql: "INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
|
||||||
|
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
|
||||||
|
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
|
||||||
|
UPDATE categories SET is_inputable = 0 WHERE id = 31;
|
||||||
|
UPDATE keywords SET category_id = 310 WHERE keyword = 'BELAIR' AND category_id = 31;
|
||||||
|
UPDATE keywords SET category_id = 311 WHERE keyword = 'PRYSM' AND category_id = 31;
|
||||||
|
UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -101,6 +113,7 @@ pub fn run() {
|
||||||
commands::get_new_profile_init_sql,
|
commands::get_new_profile_init_sql,
|
||||||
commands::hash_pin,
|
commands::hash_pin,
|
||||||
commands::verify_pin,
|
commands::verify_pin,
|
||||||
|
commands::repair_migrations,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Resultat",
|
"productName": "Simpl Resultat",
|
||||||
"version": "0.3.4",
|
"version": "0.6.6",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["nsis", "deb", "rpm", "appimage"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|
@ -35,12 +35,12 @@
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE2MTAyRTg2N0Q3OTBBNDAKUldSQUNubDloaTRRRm1hSzdmekpzNThHQ0N0MXpYSklIR012eFlYVHhadUpDbndkSTJUWmNMRk4K",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/Le-King-Fu/simpl-resultat/releases/latest/download/latest.json"
|
"https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json"
|
||||||
],
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "basicUi"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/App.tsx
53
src/App.tsx
|
|
@ -1,5 +1,6 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useProfile } from "./contexts/ProfileContext";
|
import { useProfile } from "./contexts/ProfileContext";
|
||||||
import AppShell from "./components/layout/AppShell";
|
import AppShell from "./components/layout/AppShell";
|
||||||
import DashboardPage from "./pages/DashboardPage";
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
|
@ -11,18 +12,59 @@ import BudgetPage from "./pages/BudgetPage";
|
||||||
import ReportsPage from "./pages/ReportsPage";
|
import ReportsPage from "./pages/ReportsPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
import ChangelogPage from "./pages/ChangelogPage";
|
||||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
|
import ErrorPage from "./components/shared/ErrorPage";
|
||||||
|
|
||||||
|
const STARTUP_TIMEOUT_MS = 10_000;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||||
const [dbReady, setDbReady] = useState(false);
|
const [dbReady, setDbReady] = useState(false);
|
||||||
|
const [startupError, setStartupError] = useState<string | null>(null);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeProfile && !isLoading) {
|
if (activeProfile && !isLoading) {
|
||||||
setDbReady(false);
|
setDbReady(false);
|
||||||
connectActiveProfile().then(() => setDbReady(true));
|
setStartupError(null);
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setStartupError(t("error.startupTimeout"));
|
||||||
|
}, STARTUP_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const attemptConnect = async (attempt: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await connectActiveProfile();
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setDbReady(true);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||||
|
if (!cancelledRef.current) return attemptConnect(attempt + 1);
|
||||||
|
} else {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setStartupError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptConnect(1);
|
||||||
}
|
}
|
||||||
}, [activeProfile, isLoading, connectActiveProfile]);
|
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,6 +74,10 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startupError) {
|
||||||
|
return <ErrorPage error={startupError} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeProfile) {
|
if (!activeProfile) {
|
||||||
return <ProfileSelectionPage />;
|
return <ProfileSelectionPage />;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +103,7 @@ export default function App() {
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
|
<Route path="/changelog" element={<ChangelogPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useRef, useEffect, Fragment } from "react";
|
import { useState, useRef, useEffect, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -16,6 +17,8 @@ const MONTH_KEYS = [
|
||||||
"months.sep", "months.oct", "months.nov", "months.dec",
|
"months.sep", "months.oct", "months.nov", "months.dec",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
interface BudgetTableProps {
|
interface BudgetTableProps {
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||||
|
|
@ -27,6 +30,18 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||||||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||||||
const [editingValue, setEditingValue] = useState("");
|
const [editingValue, setEditingValue] = useState("");
|
||||||
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === null ? true : stored === "top";
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSubtotals = () => {
|
||||||
|
setSubtotalsOnTop((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const annualInputRef = useRef<HTMLInputElement>(null);
|
const annualInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -118,10 +133,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
income: "budget.income",
|
income: "budget.income",
|
||||||
transfer: "budget.transfers",
|
transfer: "budget.transfers",
|
||||||
};
|
};
|
||||||
|
const typeTotalKeys: Record<string, string> = {
|
||||||
|
expense: "budget.totalExpenses",
|
||||||
|
income: "budget.totalIncome",
|
||||||
|
transfer: "budget.totalTransfers",
|
||||||
|
};
|
||||||
|
|
||||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
||||||
const monthTotals: number[] = Array(12).fill(0);
|
const monthTotals: number[] = Array(12).fill(0);
|
||||||
let annualTotal = 0;
|
let annualTotal = 0;
|
||||||
|
let prevYearTotal = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||||
const sign = signFor(row.category_type);
|
const sign = signFor(row.category_type);
|
||||||
|
|
@ -129,9 +150,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
monthTotals[m] += row.months[m] * sign;
|
monthTotals[m] += row.months[m] * sign;
|
||||||
}
|
}
|
||||||
annualTotal += row.annual * sign;
|
annualTotal += row.annual * sign;
|
||||||
|
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCols = 14; // category + annual + 12 months
|
const totalCols = 15; // category + prev year + annual + 12 months
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -150,30 +172,38 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
const renderRow = (row: BudgetYearRow) => {
|
const renderRow = (row: BudgetYearRow) => {
|
||||||
const sign = signFor(row.category_type);
|
const sign = signFor(row.category_type);
|
||||||
const isChild = row.parent_id !== null && !row.is_parent;
|
const isChild = row.parent_id !== null && !row.is_parent;
|
||||||
|
const depth = row.depth ?? (isChild ? 1 : 0);
|
||||||
// Unique key: parent rows and "(direct)" fake children can share the same category_id
|
// Unique key: parent rows and "(direct)" fake children can share the same category_id
|
||||||
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
||||||
|
|
||||||
if (row.is_parent) {
|
if (row.is_parent) {
|
||||||
// Parent subtotal row: read-only, bold, distinct background
|
// Parent subtotal row: read-only, bold, distinct background
|
||||||
|
const parentDepth = row.depth ?? 0;
|
||||||
|
const isTopParent = parentDepth === 0;
|
||||||
|
const isIntermediateParent = parentDepth >= 1;
|
||||||
|
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
className="border-b border-[var(--border)] bg-[var(--muted)]/30"
|
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
||||||
>
|
>
|
||||||
<td className="py-2 px-3 sticky left-0 bg-[var(--muted)]/30 z-10">
|
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: row.category_color }}
|
style={{ backgroundColor: row.category_color }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-xs font-semibold">{row.category_name}</span>
|
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-2 text-right text-xs font-semibold">
|
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
||||||
|
{formatSigned(row.previousYearTotal)}
|
||||||
|
</td>
|
||||||
|
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||||||
{formatSigned(row.annual * sign)}
|
{formatSigned(row.annual * sign)}
|
||||||
</td>
|
</td>
|
||||||
{row.months.map((val, mIdx) => (
|
{row.months.map((val, mIdx) => (
|
||||||
<td key={mIdx} className="py-2 px-2 text-right text-xs font-semibold">
|
<td key={mIdx} className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||||||
{formatSigned(val * sign)}
|
{formatSigned(val * sign)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -188,7 +218,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Category name - sticky */}
|
{/* Category name - sticky */}
|
||||||
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${isChild ? "pl-8 pr-3" : "px-3"}`}>
|
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -197,6 +227,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<span className="truncate text-xs">{row.category_name}</span>
|
<span className="truncate text-xs">{row.category_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
{/* Previous year total — read-only */}
|
||||||
|
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
|
||||||
|
<span className="text-xs px-1 py-0.5">
|
||||||
|
{formatSigned(row.previousYearTotal)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
{/* Annual total — editable */}
|
{/* Annual total — editable */}
|
||||||
<td className="py-2 px-2 text-right">
|
<td className="py-2 px-2 text-right">
|
||||||
{editingAnnual?.categoryId === row.category_id ? (
|
{editingAnnual?.categoryId === row.category_id ? (
|
||||||
|
|
@ -214,7 +250,8 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||||||
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
|
title={t("budget.clickToEdit")}
|
||||||
|
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
|
||||||
>
|
>
|
||||||
{formatSigned(row.annual * sign)}
|
{formatSigned(row.annual * sign)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -246,7 +283,8 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||||
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
|
title={t("budget.clickToEdit")}
|
||||||
|
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
|
||||||
>
|
>
|
||||||
{formatSigned(val * sign)}
|
{formatSigned(val * sign)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -258,13 +296,26 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
<thead>
|
<thead className="sticky top-0 z-20">
|
||||||
<tr className="border-b border-[var(--border)]">
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-10 min-w-[140px]">
|
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||||||
{t("budget.category")}
|
{t("budget.category")}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||||
|
{t("budget.previousYear")}
|
||||||
|
</th>
|
||||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||||
{t("budget.annual")}
|
{t("budget.annual")}
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -279,6 +330,18 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
{typeOrder.map((type) => {
|
{typeOrder.map((type) => {
|
||||||
const group = grouped[type];
|
const group = grouped[type];
|
||||||
if (!group || group.length === 0) return null;
|
if (!group || group.length === 0) return null;
|
||||||
|
const sign = signFor(type);
|
||||||
|
const leaves = group.filter((r) => !r.is_parent);
|
||||||
|
const sectionMonthTotals: number[] = Array(12).fill(0);
|
||||||
|
let sectionAnnualTotal = 0;
|
||||||
|
let sectionPrevYearTotal = 0;
|
||||||
|
for (const row of leaves) {
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
sectionMonthTotals[m] += row.months[m] * sign;
|
||||||
|
}
|
||||||
|
sectionAnnualTotal += row.annual * sign;
|
||||||
|
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment key={type}>
|
<Fragment key={type}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -289,22 +352,36 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
{t(typeLabelKeys[type])}
|
{t(typeLabelKeys[type])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{group.map((row) => renderRow(row))}
|
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
||||||
|
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||||||
|
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||||||
|
{t(typeTotalKeys[type])}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
|
||||||
|
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
||||||
|
{sectionMonthTotals.map((total, mIdx) => (
|
||||||
|
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
||||||
|
{formatSigned(total)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Totals row */}
|
{/* Totals row */}
|
||||||
<tr className="bg-[var(--muted)] font-semibold">
|
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
||||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
|
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
||||||
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
|
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
||||||
|
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
||||||
{monthTotals.map((total, mIdx) => (
|
{monthTotals.map((total, mIdx) => (
|
||||||
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
|
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
||||||
{formatSigned(total)}
|
{formatSigned(total)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getAllKeywordsWithCategory,
|
getAllKeywordsWithCategory,
|
||||||
type KeywordWithCategory,
|
type KeywordWithCategory,
|
||||||
|
|
@ -15,10 +15,12 @@ function normalize(str: string): string {
|
||||||
|
|
||||||
interface AllKeywordsPanelProps {
|
interface AllKeywordsPanelProps {
|
||||||
onSelectCategory: (id: number) => void;
|
onSelectCategory: (id: number) => void;
|
||||||
|
onRemove: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AllKeywordsPanel({
|
export default function AllKeywordsPanel({
|
||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
|
onRemove,
|
||||||
}: AllKeywordsPanelProps) {
|
}: AllKeywordsPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [keywords, setKeywords] = useState<KeywordWithCategory[]>([]);
|
const [keywords, setKeywords] = useState<KeywordWithCategory[]>([]);
|
||||||
|
|
@ -89,6 +91,7 @@ export default function AllKeywordsPanel({
|
||||||
</th>
|
</th>
|
||||||
<th className="pb-2 font-medium">{t("categories.priority")}</th>
|
<th className="pb-2 font-medium">{t("categories.priority")}</th>
|
||||||
<th className="pb-2 font-medium">{t("transactions.category")}</th>
|
<th className="pb-2 font-medium">{t("transactions.category")}</th>
|
||||||
|
<th className="pb-2 w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -111,6 +114,17 @@ export default function AllKeywordsPanel({
|
||||||
{k.category_name}
|
{k.category_name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onRemove(k.id);
|
||||||
|
setKeywords((prev) => prev.filter((kw) => kw.id !== k.id));
|
||||||
|
}}
|
||||||
|
className="p-1 text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,36 @@ export default function CategoryForm({
|
||||||
setForm(initialData);
|
setForm(initialData);
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
const parentOptions = categories.filter(
|
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
|
||||||
(c) => c.parent_id === null
|
// Also build indentation info
|
||||||
);
|
const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (cat.parent_id === null) {
|
||||||
|
// Level 0 — always allowed as parent
|
||||||
|
parentOptions.push({ ...cat, indent: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (cat.parent_id !== null) {
|
||||||
|
// Check if this category's parent is a root (making this level 1)
|
||||||
|
const parent = categories.find((c) => c.id === cat.parent_id);
|
||||||
|
if (parent && parent.parent_id === null) {
|
||||||
|
// Level 1 — allowed as parent (would create level 3 children)
|
||||||
|
parentOptions.push({ ...cat, indent: 1 });
|
||||||
|
}
|
||||||
|
// Level 2 categories are NOT shown (would create level 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort to keep hierarchy order: group by root parent sort_order
|
||||||
|
parentOptions.sort((a, b) => {
|
||||||
|
const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id);
|
||||||
|
const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id);
|
||||||
|
const orderA = rootA?.sort_order ?? 999;
|
||||||
|
const orderB = rootB?.sort_order ?? 999;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
if (a.indent !== b.indent) return a.indent - b.indent;
|
||||||
|
return a.sort_order - b.sort_order;
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -113,7 +140,7 @@ export default function CategoryForm({
|
||||||
<option value="">{t("categories.noParent")}</option>
|
<option value="">{t("categories.noParent")}</option>
|
||||||
{parentOptions.map((c) => (
|
{parentOptions.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -35,25 +35,24 @@ interface Props {
|
||||||
onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise<void>;
|
onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSubtreeDepth(node: CategoryTreeNode): number {
|
||||||
|
if (node.children.length === 0) return 0;
|
||||||
|
return 1 + Math.max(...node.children.map(getSubtreeDepth));
|
||||||
|
}
|
||||||
|
|
||||||
function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] {
|
function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] {
|
||||||
const items: FlatItem[] = [];
|
const items: FlatItem[] = [];
|
||||||
for (const node of tree) {
|
function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) {
|
||||||
const hasChildren = node.children.length > 0;
|
for (const node of nodes) {
|
||||||
const isExpanded = expandedSet.has(node.id);
|
const hasChildren = node.children.length > 0;
|
||||||
items.push({ id: node.id, node, depth: 0, parentId: null, isExpanded, hasChildren });
|
const isExpanded = expandedSet.has(node.id);
|
||||||
if (isExpanded) {
|
items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren });
|
||||||
for (const child of node.children) {
|
if (isExpanded && hasChildren) {
|
||||||
items.push({
|
recurse(node.children, depth + 1, node.id);
|
||||||
id: child.id,
|
|
||||||
node: child,
|
|
||||||
depth: 1,
|
|
||||||
parentId: node.id,
|
|
||||||
isExpanded: false,
|
|
||||||
hasChildren: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recurse(tree, 0, null);
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,9 +190,15 @@ function SortableTreeRow({
|
||||||
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
|
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(() => {
|
const [expanded, setExpanded] = useState<Set<number>>(() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const node of tree) {
|
function collectExpandable(nodes: CategoryTreeNode[]) {
|
||||||
if (node.children.length > 0) ids.add(node.id);
|
for (const node of nodes) {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
ids.add(node.id);
|
||||||
|
collectExpandable(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
collectExpandable(tree);
|
||||||
return ids;
|
return ids;
|
||||||
});
|
});
|
||||||
const [activeId, setActiveId] = useState<number | null>(null);
|
const [activeId, setActiveId] = useState<number | null>(null);
|
||||||
|
|
@ -238,40 +243,31 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor
|
||||||
const activeItem = flatItems[activeIdx];
|
const activeItem = flatItems[activeIdx];
|
||||||
const overItem = flatItems[overIdx];
|
const overItem = flatItems[overIdx];
|
||||||
|
|
||||||
|
// Compute the depth of the active item's subtree
|
||||||
|
const activeSubtreeDepth = getSubtreeDepth(activeItem.node);
|
||||||
|
|
||||||
// Determine the new parent and index
|
// Determine the new parent and index
|
||||||
let newParentId: number | null;
|
let newParentId: number | null;
|
||||||
let newIndex: number;
|
let newIndex: number;
|
||||||
|
|
||||||
if (overItem.depth === 0) {
|
if (overItem.depth === 0) {
|
||||||
// Dropping onto/near a root item
|
// Dropping onto/near a root item — same depth reorder or moving to root
|
||||||
|
newParentId = null;
|
||||||
|
const rootItems = flatItems.filter((i) => i.depth === 0);
|
||||||
|
const overRootIdx = rootItems.findIndex((i) => i.id === over.id);
|
||||||
if (activeItem.depth === 0) {
|
if (activeItem.depth === 0) {
|
||||||
// Root reorder: keep as root
|
|
||||||
newParentId = null;
|
|
||||||
// Count the root index of the over item
|
|
||||||
const rootItems = flatItems.filter((i) => i.depth === 0);
|
|
||||||
const overRootIdx = rootItems.findIndex((i) => i.id === over.id);
|
|
||||||
newIndex = overRootIdx;
|
newIndex = overRootIdx;
|
||||||
} else {
|
} else {
|
||||||
// Child moving to root level
|
|
||||||
newParentId = null;
|
|
||||||
const rootItems = flatItems.filter((i) => i.depth === 0);
|
|
||||||
const overRootIdx = rootItems.findIndex((i) => i.id === over.id);
|
|
||||||
newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx;
|
newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dropping onto/near a child item
|
// Dropping onto/near a non-root item — adopt same parent
|
||||||
if (activeItem.hasChildren) {
|
|
||||||
// Block: moving a root with children to become a child (would create 3 levels)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newParentId = overItem.parentId;
|
newParentId = overItem.parentId;
|
||||||
// Find the index within that parent's children
|
|
||||||
const siblings = flatItems.filter(
|
const siblings = flatItems.filter(
|
||||||
(i) => i.depth === 1 && i.parentId === overItem.parentId
|
(i) => i.depth === overItem.depth && i.parentId === overItem.parentId
|
||||||
);
|
);
|
||||||
const overSiblingIdx = siblings.findIndex((i) => i.id === over.id);
|
const overSiblingIdx = siblings.findIndex((i) => i.id === over.id);
|
||||||
newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx;
|
newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx;
|
||||||
// If moving from same parent, adjust index
|
|
||||||
if (activeItem.parentId === newParentId) {
|
if (activeItem.parentId === newParentId) {
|
||||||
const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id);
|
const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id);
|
||||||
if (activeSiblingIdx < overSiblingIdx) {
|
if (activeSiblingIdx < overSiblingIdx) {
|
||||||
|
|
@ -282,8 +278,9 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate 2-level constraint: can't drop a root with children into a child position
|
// Validate 3-level constraint: targetDepth + subtreeDepth must be <= 2 (max index)
|
||||||
if (newParentId !== null && activeItem.hasChildren) {
|
const targetDepth = newParentId === null ? 0 : overItem.depth;
|
||||||
|
if (targetDepth + activeSubtreeDepth > 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export default function CategoryPieChart({
|
||||||
}: CategoryPieChartProps) {
|
}: CategoryPieChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||||
|
const [isChartHovered, setIsChartHovered] = useState(false);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||||
|
|
||||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||||
|
|
@ -36,17 +37,14 @@ export default function CategoryPieChart({
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||||
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
|
||||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||||
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
|
||||||
|
|
||||||
{hiddenCategories.size > 0 && (
|
{hiddenCategories.size > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
|
|
@ -69,8 +67,12 @@ export default function CategoryPieChart({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
<div
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={() => setIsChartHovered(true)}
|
||||||
|
onMouseLeave={() => setIsChartHovered(false)}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartPatternDefs
|
<ChartPatternDefs
|
||||||
prefix="cat-pie"
|
prefix="cat-pie"
|
||||||
|
|
@ -82,8 +84,8 @@ export default function CategoryPieChart({
|
||||||
nameKey="category_name"
|
nameKey="category_name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={50}
|
innerRadius={35}
|
||||||
outerRadius={100}
|
outerRadius={75}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{visibleData.map((item, index) => (
|
{visibleData.map((item, index) => (
|
||||||
|
|
@ -97,9 +99,11 @@ export default function CategoryPieChart({
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) =>
|
formatter={(value) => {
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value));
|
||||||
}
|
const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : "";
|
||||||
|
return `${formatted}${pct}`;
|
||||||
|
}}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "var(--card)",
|
backgroundColor: "var(--card)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
|
|
@ -113,13 +117,14 @@ export default function CategoryPieChart({
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const isHidden = hiddenCategories.has(item.category_name);
|
const isHidden = hiddenCategories.has(item.category_name);
|
||||||
|
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||||
|
|
@ -129,7 +134,7 @@ export default function CategoryPieChart({
|
||||||
>
|
>
|
||||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
||||||
<span className="text-[var(--muted-foreground)]">
|
<span className="text-[var(--muted-foreground)]">
|
||||||
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
|
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,66 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
import type { DashboardPeriod } from "../../shared/types";
|
import type { DashboardPeriod } from "../../shared/types";
|
||||||
|
|
||||||
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"];
|
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "year", "12months", "all"];
|
||||||
|
|
||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: DashboardPeriod;
|
value: DashboardPeriod;
|
||||||
onChange: (period: DashboardPeriod) => void;
|
onChange: (period: DashboardPeriod) => void;
|
||||||
|
customDateFrom?: string;
|
||||||
|
customDateTo?: string;
|
||||||
|
onCustomDateChange?: (dateFrom: string, dateTo: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
export default function PeriodSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
customDateFrom,
|
||||||
|
customDateTo,
|
||||||
|
onCustomDateChange,
|
||||||
|
}: PeriodSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [showCustom, setShowCustom] = useState(false);
|
||||||
|
const [localFrom, setLocalFrom] = useState(customDateFrom ?? "");
|
||||||
|
const [localTo, setLocalTo] = useState(customDateTo ?? "");
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customDateFrom) setLocalFrom(customDateFrom);
|
||||||
|
if (customDateTo) setLocalTo(customDateTo);
|
||||||
|
}, [customDateFrom, customDateTo]);
|
||||||
|
|
||||||
|
// Close panel on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCustom) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||||
|
setShowCustom(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [showCustom]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (localFrom && localTo && localFrom <= localTo && onCustomDateChange) {
|
||||||
|
onCustomDateChange(localFrom, localTo);
|
||||||
|
setShowCustom(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = localFrom && localTo && localFrom <= localTo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2 items-center relative">
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => onChange(p)}
|
onClick={() => {
|
||||||
|
onChange(p);
|
||||||
|
setShowCustom(false);
|
||||||
|
}}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
p === value
|
p === value
|
||||||
? "bg-[var(--primary)] text-white"
|
? "bg-[var(--primary)] text-white"
|
||||||
|
|
@ -26,6 +70,60 @@ export default function PeriodSelector({ value, onChange }: PeriodSelectorProps)
|
||||||
{t(`dashboard.period.${p}`)}
|
{t(`dashboard.period.${p}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{onCustomDateChange && (
|
||||||
|
<div ref={panelRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCustom((prev) => !prev)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors inline-flex items-center gap-1.5 ${
|
||||||
|
value === "custom"
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar size={14} />
|
||||||
|
{t("dashboard.period.custom")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCustom && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 z-50 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg p-4 flex flex-col gap-3 min-w-[280px]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.dateFrom")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={localFrom}
|
||||||
|
onChange={(e) => setLocalFrom(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.dateTo")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={localTo}
|
||||||
|
onChange={(e) => setLocalTo(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!isValid}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isValid
|
||||||
|
? "bg-[var(--primary)] text-white hover:opacity-90"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("dashboard.apply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -22,8 +24,22 @@ interface BudgetVsActualTableProps {
|
||||||
data: BudgetVsActualRow[];
|
data: BudgetVsActualRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === null ? true : stored === "top";
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSubtotals = () => {
|
||||||
|
setSubtotalsOnTop((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,6 +57,11 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
income: t("budget.income"),
|
income: t("budget.income"),
|
||||||
transfer: t("budget.transfers"),
|
transfer: t("budget.transfers"),
|
||||||
};
|
};
|
||||||
|
const typeTotalKeys: Record<SectionType, string> = {
|
||||||
|
expense: "budget.totalExpenses",
|
||||||
|
income: "budget.totalIncome",
|
||||||
|
transfer: "budget.totalTransfers",
|
||||||
|
};
|
||||||
|
|
||||||
let currentType: SectionType | null = null;
|
let currentType: SectionType | null = null;
|
||||||
for (const row of data) {
|
for (const row of data) {
|
||||||
|
|
@ -68,66 +89,101 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead className="sticky top-0 z-20">
|
||||||
<tr className="border-b border-[var(--border)]">
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
|
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||||
{t("budget.category")}
|
{t("budget.category")}
|
||||||
</th>
|
</th>
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
{t("reports.bva.monthly")}
|
{t("reports.bva.monthly")}
|
||||||
</th>
|
</th>
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
{t("reports.bva.ytd")}
|
{t("reports.bva.ytd")}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="border-b border-[var(--border)]">
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
{t("budget.actual")}
|
{t("budget.actual")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("budget.planned")}
|
{t("budget.planned")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("reports.bva.dollarVar")}
|
{t("reports.bva.dollarVar")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("reports.bva.pctVar")}
|
{t("reports.bva.pctVar")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
{t("budget.actual")}
|
{t("budget.actual")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("budget.planned")}
|
{t("budget.planned")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("reports.bva.dollarVar")}
|
{t("reports.bva.dollarVar")}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
{t("reports.bva.pctVar")}
|
{t("reports.bva.pctVar")}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => {
|
||||||
|
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
||||||
|
const sectionTotals = sectionLeaves.reduce(
|
||||||
|
(acc, r) => ({
|
||||||
|
monthActual: acc.monthActual + r.monthActual,
|
||||||
|
monthBudget: acc.monthBudget + r.monthBudget,
|
||||||
|
monthVariation: acc.monthVariation + r.monthVariation,
|
||||||
|
ytdActual: acc.ytdActual + r.ytdActual,
|
||||||
|
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||||
|
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||||
|
}),
|
||||||
|
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
||||||
|
);
|
||||||
|
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
|
||||||
|
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
||||||
|
return (
|
||||||
<Fragment key={section.type}>
|
<Fragment key={section.type}>
|
||||||
<tr className="bg-[var(--muted)]/50">
|
<tr className="bg-[var(--muted)]">
|
||||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||||
{section.label}
|
{section.label}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{section.rows.map((row) => {
|
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||||
const isParent = row.is_parent;
|
const isParent = row.is_parent;
|
||||||
const isChild = row.parent_id !== null && !row.is_parent;
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
|
const isTopParent = isParent && depth === 0;
|
||||||
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.category_id}-${row.is_parent}`}
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isParent ? "bg-[var(--muted)]/30 font-semibold" : ""
|
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||||
|
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className={`px-3 py-1.5 ${isChild ? "pl-8" : ""}`}>
|
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||||
|
isTopParent
|
||||||
|
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||||
|
: isIntermediateParent
|
||||||
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
|
}`}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -159,34 +215,59 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||||
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{pctFormatter(sectionMonthPct)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{pctFormatter(sectionYtdPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{/* Grand totals */}
|
{/* Grand totals */}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
||||||
<td className="px-3 py-2">{t("common.total")}</td>
|
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
||||||
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(totals.monthActual)}
|
{cadFormatter(totals.monthActual)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
|
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||||
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||||
{cadFormatter(totals.monthVariation)}
|
{cadFormatter(totals.monthVariation)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||||
{pctFormatter(totalMonthPct)}
|
{pctFormatter(totalMonthPct)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(totals.ytdActual)}
|
{cadFormatter(totals.ytdActual)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
|
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
||||||
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||||
{cadFormatter(totals.ytdVariation)}
|
{cadFormatter(totals.ytdVariation)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||||
{pctFormatter(totalYtdPct)}
|
{pctFormatter(totalYtdPct)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
|
LabelList,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Eye } from "lucide-react";
|
import { Eye } from "lucide-react";
|
||||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
|
@ -23,6 +24,7 @@ interface CategoryBarChartProps {
|
||||||
onToggleHidden: (categoryName: string) => void;
|
onToggleHidden: (categoryName: string) => void;
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||||
|
showAmounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryBarChart({
|
export default function CategoryBarChart({
|
||||||
|
|
@ -31,9 +33,11 @@ export default function CategoryBarChart({
|
||||||
onToggleHidden,
|
onToggleHidden,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
|
showAmounts,
|
||||||
}: CategoryBarChartProps) {
|
}: CategoryBarChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||||
|
|
||||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||||
|
|
@ -93,7 +97,7 @@ export default function CategoryBarChart({
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="category_name"
|
dataKey="category_name"
|
||||||
width={120}
|
width={120}
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
||||||
stroke="var(--border)"
|
stroke="var(--border)"
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
@ -103,7 +107,9 @@ export default function CategoryBarChart({
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
color: "var(--foreground)",
|
color: "var(--foreground)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
}}
|
}}
|
||||||
|
wrapperStyle={{ zIndex: 50 }}
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -112,11 +118,21 @@ export default function CategoryBarChart({
|
||||||
<Cell
|
<Cell
|
||||||
key={index}
|
key={index}
|
||||||
fill={getPatternFill("cat-bar", index, item.category_color)}
|
fill={getPatternFill("cat-bar", index, item.category_color)}
|
||||||
onMouseEnter={() => { hoveredRef.current = item; }}
|
fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3}
|
||||||
onMouseLeave={() => { hoveredRef.current = null; }}
|
onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }}
|
||||||
|
onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }}
|
||||||
cursor="context-menu"
|
cursor="context-menu"
|
||||||
|
style={{ transition: "fill-opacity 150ms" }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{showAmounts && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="total"
|
||||||
|
position="right"
|
||||||
|
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||||
|
style={{ fill: "var(--foreground)", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Legend,
|
Legend,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
|
LabelList,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Eye } from "lucide-react";
|
import { Eye } from "lucide-react";
|
||||||
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
|
@ -30,6 +31,7 @@ interface CategoryOverTimeChartProps {
|
||||||
onToggleHidden: (categoryName: string) => void;
|
onToggleHidden: (categoryName: string) => void;
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||||
|
showAmounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryOverTimeChart({
|
export default function CategoryOverTimeChart({
|
||||||
|
|
@ -38,9 +40,11 @@ export default function CategoryOverTimeChart({
|
||||||
onToggleHidden,
|
onToggleHidden,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
|
showAmounts,
|
||||||
}: CategoryOverTimeChartProps) {
|
}: CategoryOverTimeChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<string | null>(null);
|
const hoveredRef = useRef<string | null>(null);
|
||||||
|
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||||
|
|
||||||
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
||||||
|
|
@ -109,28 +113,52 @@ export default function CategoryOverTimeChart({
|
||||||
width={80}
|
width={80}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
formatter={(value: unknown, name: unknown) => {
|
||||||
|
if (hoveredCategory && name !== hoveredCategory) return [null, null];
|
||||||
|
return [cadFormatter(Number(value) || 0), String(name)];
|
||||||
|
}}
|
||||||
labelFormatter={(label) => formatMonth(String(label))}
|
labelFormatter={(label) => formatMonth(String(label))}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "var(--card)",
|
backgroundColor: "var(--card)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
color: "var(--foreground)",
|
color: "var(--foreground)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
}}
|
}}
|
||||||
|
wrapperStyle={{ zIndex: 50 }}
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
|
filterNull
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoveredCategory(null)}
|
||||||
|
wrapperStyle={{ cursor: "pointer" }}
|
||||||
|
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
|
||||||
{categoryEntries.map((c) => (
|
{categoryEntries.map((c) => (
|
||||||
<Bar
|
<Bar
|
||||||
key={c.name}
|
key={c.name}
|
||||||
dataKey={c.name}
|
dataKey={c.name}
|
||||||
stackId="stack"
|
stackId="stack"
|
||||||
fill={getPatternFill("cat-time", c.index, c.color)}
|
fill={getPatternFill("cat-time", c.index, c.color)}
|
||||||
onMouseEnter={() => { hoveredRef.current = c.name; }}
|
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
||||||
onMouseLeave={() => { hoveredRef.current = null; }}
|
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
||||||
|
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
||||||
cursor="context-menu"
|
cursor="context-menu"
|
||||||
/>
|
style={{ transition: "fill-opacity 150ms" }}
|
||||||
|
>
|
||||||
|
{showAmounts && (
|
||||||
|
<LabelList
|
||||||
|
dataKey={c.name}
|
||||||
|
position="center"
|
||||||
|
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
|
||||||
|
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bar>
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
|
||||||
111
src/components/reports/CategoryOverTimeTable.tsx
Normal file
111
src/components/reports/CategoryOverTimeTable.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { CategoryOverTimeData } from "../../shared/types";
|
||||||
|
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
function formatMonth(month: string): string {
|
||||||
|
const [year, m] = month.split("-");
|
||||||
|
const date = new Date(Number(year), Number(m) - 1);
|
||||||
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryOverTimeTableProps {
|
||||||
|
data: CategoryOverTimeData;
|
||||||
|
hiddenCategories?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCategories = hiddenCategories?.size
|
||||||
|
? data.categories.filter((name) => !hiddenCategories.has(name))
|
||||||
|
: data.categories;
|
||||||
|
|
||||||
|
const months = data.data.map((d) => d.month);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
|
||||||
|
{t("budget.category")}
|
||||||
|
</th>
|
||||||
|
{months.map((month) => (
|
||||||
|
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
|
||||||
|
{formatMonth(month)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
|
||||||
|
{t("common.total")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleCategories.map((category) => {
|
||||||
|
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
||||||
|
return (
|
||||||
|
<tr key={category} className="border-b border-[var(--border)]/50">
|
||||||
|
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: data.colors[category] }}
|
||||||
|
/>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{months.map((month) => {
|
||||||
|
const monthData = data.data.find((d) => d.month === month);
|
||||||
|
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||||
|
return (
|
||||||
|
<td key={month} className="text-right px-3 py-1.5">
|
||||||
|
{value ? cadFormatter(value) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(rowTotal)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||||
|
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
||||||
|
{months.map((month) => {
|
||||||
|
const monthData = data.data.find((d) => d.month === month);
|
||||||
|
const monthTotal = visibleCategories.reduce(
|
||||||
|
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<td key={month} className="text-right px-3 py-3">
|
||||||
|
{cadFormatter(monthTotal)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(
|
||||||
|
visibleCategories.reduce(
|
||||||
|
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/reports/CategoryTable.tsx
Normal file
74
src/components/reports/CategoryTable.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
interface CategoryTableProps {
|
||||||
|
data: CategoryBreakdownItem[];
|
||||||
|
hiddenCategories?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const visibleData = hiddenCategories?.size
|
||||||
|
? data.filter((d) => !hiddenCategories.has(d.category_name))
|
||||||
|
: data;
|
||||||
|
|
||||||
|
if (visibleData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("budget.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("common.total")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
%
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleData.map((row) => (
|
||||||
|
<tr key={row.category_id ?? "uncategorized"} className="border-b border-[var(--border)]/50">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5">{cadFormatter(row.total)}</td>
|
||||||
|
<td className="text-right px-3 py-1.5 text-[var(--muted-foreground)]">
|
||||||
|
{grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||||
|
<td className="px-3 py-3">{t("common.total")}</td>
|
||||||
|
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
|
||||||
|
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/reports/DynamicReport.tsx
Normal file
106
src/components/reports/DynamicReport.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||||
|
import DynamicReportPanel from "./DynamicReportPanel";
|
||||||
|
import DynamicReportTable from "./DynamicReportTable";
|
||||||
|
import DynamicReportChart from "./DynamicReportChart";
|
||||||
|
|
||||||
|
type ViewMode = "table" | "chart" | "both";
|
||||||
|
|
||||||
|
interface DynamicReportProps {
|
||||||
|
config: PivotConfig;
|
||||||
|
result: PivotResult;
|
||||||
|
onConfigChange: (config: PivotConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
|
||||||
|
|
||||||
|
// Escape key exits fullscreen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setFullscreen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
|
||||||
|
|
||||||
|
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
|
||||||
|
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||||
|
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||||
|
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
fullscreen
|
||||||
|
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setViewMode(mode)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
mode === viewMode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||||
|
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!hasConfig && (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.pivot.noConfig")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{hasConfig && (viewMode === "table" || viewMode === "both") && (
|
||||||
|
<DynamicReportTable config={config} result={result} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
|
||||||
|
<DynamicReportChart config={config} result={result} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side panel */}
|
||||||
|
<DynamicReportPanel
|
||||||
|
config={config}
|
||||||
|
onChange={onConfigChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/reports/DynamicReportChart.tsx
Normal file
143
src/components/reports/DynamicReportChart.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||||
|
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||||
|
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
// Generate distinct colors for series
|
||||||
|
const SERIES_COLORS = [
|
||||||
|
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
|
||||||
|
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
|
||||||
|
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DynamicReportChartProps {
|
||||||
|
config: PivotConfig;
|
||||||
|
result: PivotResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const colDims = config.columns;
|
||||||
|
const rowDim = config.rows[0];
|
||||||
|
const measure = config.values[0] || "periodic";
|
||||||
|
|
||||||
|
// X-axis = composite column key (or first row dimension if no columns)
|
||||||
|
const hasColDims = colDims.length > 0;
|
||||||
|
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||||
|
|
||||||
|
// Build composite column key per row
|
||||||
|
const getColKey = (r: typeof result.rows[0]) =>
|
||||||
|
colDims.map((d) => r.keys[d] || "").join(" — ");
|
||||||
|
|
||||||
|
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
|
||||||
|
const seriesDim = hasColDims ? rowDim : undefined;
|
||||||
|
|
||||||
|
// Collect unique x and series values
|
||||||
|
const xValues = hasColDims
|
||||||
|
? [...new Set(result.rows.map(getColKey))].sort()
|
||||||
|
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
|
||||||
|
const seriesVals = seriesDim
|
||||||
|
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
|
||||||
|
: [measure];
|
||||||
|
|
||||||
|
// Build chart data: one entry per x value
|
||||||
|
const data = xValues.map((xVal) => {
|
||||||
|
const entry: Record<string, string | number> = { name: xVal };
|
||||||
|
if (seriesDim) {
|
||||||
|
for (const sv of seriesVals) {
|
||||||
|
const matchingRows = result.rows.filter(
|
||||||
|
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
|
||||||
|
);
|
||||||
|
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const matchingRows = result.rows.filter((r) =>
|
||||||
|
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
|
||||||
|
);
|
||||||
|
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
seriesVals.forEach((sv, i) => {
|
||||||
|
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
|
||||||
|
}, [config, result]);
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryEntries = seriesKeys.map((key, index) => ({
|
||||||
|
color: seriesColors[key],
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v) => cadFormatter(v)}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{seriesKeys.map((key, index) => (
|
||||||
|
<Bar
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
stackId="stack"
|
||||||
|
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
src/components/reports/DynamicReportPanel.tsx
Normal file
306
src/components/reports/DynamicReportPanel.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
|
||||||
|
import { getDynamicFilterValues } from "../../services/reportService";
|
||||||
|
|
||||||
|
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
|
||||||
|
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
|
||||||
|
|
||||||
|
interface DynamicReportPanelProps {
|
||||||
|
config: PivotConfig;
|
||||||
|
onChange: (config: PivotConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
|
||||||
|
const inRows = new Set(config.rows);
|
||||||
|
const inColumns = new Set(config.columns);
|
||||||
|
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
|
||||||
|
const assignedFields = new Set(
|
||||||
|
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
|
||||||
|
);
|
||||||
|
const assignedMeasures = new Set(config.values);
|
||||||
|
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
|
||||||
|
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
|
||||||
|
|
||||||
|
// Load filter values when a field is added to filters
|
||||||
|
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
|
||||||
|
useEffect(() => {
|
||||||
|
for (const fieldId of filterFieldIds) {
|
||||||
|
if (!filterValues[fieldId]) {
|
||||||
|
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [filterFieldIds.join(",")]);
|
||||||
|
|
||||||
|
// Close menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuTarget) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setMenuTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [menuTarget]);
|
||||||
|
|
||||||
|
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
|
||||||
|
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignTo = useCallback((zone: PivotZone) => {
|
||||||
|
if (!menuTarget) return;
|
||||||
|
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||||
|
|
||||||
|
if (menuTarget.type === "measure") {
|
||||||
|
if (zone === "values") {
|
||||||
|
next.values = [...next.values, menuTarget.id as PivotMeasureId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fieldId = menuTarget.id as PivotFieldId;
|
||||||
|
if (zone === "rows") next.rows = [...next.rows, fieldId];
|
||||||
|
else if (zone === "columns") next.columns = [...next.columns, fieldId];
|
||||||
|
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuTarget(null);
|
||||||
|
onChange(next);
|
||||||
|
}, [menuTarget, config, onChange]);
|
||||||
|
|
||||||
|
const removeFrom = (zone: PivotZone, id: string) => {
|
||||||
|
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||||
|
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
|
||||||
|
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
|
||||||
|
else if (zone === "filters") {
|
||||||
|
const { [id]: _, ...rest } = next.filters;
|
||||||
|
next.filters = rest;
|
||||||
|
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFilterInclude = (fieldId: string, value: string) => {
|
||||||
|
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||||
|
const isIncluded = entry.include.includes(value);
|
||||||
|
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
|
||||||
|
// Remove from exclude if adding to include
|
||||||
|
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
|
||||||
|
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFilterExclude = (fieldId: string, value: string) => {
|
||||||
|
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||||
|
const isExcluded = entry.exclude.includes(value);
|
||||||
|
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
|
||||||
|
// Remove from include if adding to exclude
|
||||||
|
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
|
||||||
|
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
|
||||||
|
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||||
|
|
||||||
|
// Context menu only shows zones where the field is NOT already assigned
|
||||||
|
const getAvailableZones = (fieldId: string): PivotZone[] => {
|
||||||
|
const zones: PivotZone[] = [];
|
||||||
|
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
|
||||||
|
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
|
||||||
|
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
|
||||||
|
return zones;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneLabels: Record<PivotZone, string> = {
|
||||||
|
rows: t("reports.pivot.rows"),
|
||||||
|
columns: t("reports.pivot.columns"),
|
||||||
|
filters: t("reports.pivot.filters"),
|
||||||
|
values: t("reports.pivot.values"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
|
||||||
|
{/* Available Fields */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availableFields.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={(e) => handleFieldClick(f, "field", e)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
|
||||||
|
>
|
||||||
|
{fieldLabel(f)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{availableMeasures.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={(e) => handleFieldClick(m, "measure", e)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
|
||||||
|
>
|
||||||
|
{measureLabel(m)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{availableFields.length === 0 && availableMeasures.length === 0 && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<ZoneSection
|
||||||
|
label={t("reports.pivot.rows")}
|
||||||
|
items={config.rows}
|
||||||
|
getLabel={fieldLabel}
|
||||||
|
onRemove={(id) => removeFrom("rows", id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
<ZoneSection
|
||||||
|
label={t("reports.pivot.columns")}
|
||||||
|
items={config.columns}
|
||||||
|
getLabel={fieldLabel}
|
||||||
|
onRemove={(id) => removeFrom("columns", id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
|
||||||
|
{filterFieldIds.length === 0 ? (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filterFieldIds.map((fieldId) => {
|
||||||
|
const entry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||||
|
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
|
||||||
|
return (
|
||||||
|
<div key={fieldId}>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
|
||||||
|
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(filterValues[fieldId] || []).map((val) => {
|
||||||
|
const isIncluded = entry.include.includes(val);
|
||||||
|
const isExcluded = entry.exclude.includes(val);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => toggleFilterInclude(fieldId, val)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFilterExclude(fieldId, val);
|
||||||
|
}}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
isIncluded
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: isExcluded
|
||||||
|
? "bg-[var(--negative)] text-white line-through"
|
||||||
|
: hasActive
|
||||||
|
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
|
||||||
|
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
title={t("reports.pivot.rightClickExclude")}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Values */}
|
||||||
|
<ZoneSection
|
||||||
|
label={t("reports.pivot.values")}
|
||||||
|
items={config.values}
|
||||||
|
getLabel={measureLabel}
|
||||||
|
onRemove={(id) => removeFrom("values", id)}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Context menu */}
|
||||||
|
{menuTarget && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
|
||||||
|
style={{ left: menuTarget.x, top: menuTarget.y }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
|
||||||
|
{menuTarget.type === "measure" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => assignTo("values")}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{zoneLabels.values}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
getAvailableZones(menuTarget.id).map((zone) => (
|
||||||
|
<button
|
||||||
|
key={zone}
|
||||||
|
onClick={() => assignTo(zone)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{zoneLabels[zone]}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneSection({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
getLabel,
|
||||||
|
onRemove,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
getLabel: (id: string) => string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
accent?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((id) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
|
||||||
|
accent
|
||||||
|
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||||
|
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getLabel(id)}
|
||||||
|
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
src/components/reports/DynamicReportTable.tsx
Normal file
295
src/components/reports/DynamicReportTable.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { Fragment, useState, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUpDown } from "lucide-react";
|
||||||
|
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
|
||||||
|
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "pivot-subtotals-position";
|
||||||
|
|
||||||
|
interface DynamicReportTableProps {
|
||||||
|
config: PivotConfig;
|
||||||
|
result: PivotResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pivoted row: one per unique combination of row dimensions */
|
||||||
|
interface PivotedRow {
|
||||||
|
rowKeys: Record<string, string>; // row-dimension values
|
||||||
|
cells: Record<string, Record<string, number>>; // colValue → measure → value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
|
||||||
|
function pivotRows(
|
||||||
|
rows: PivotResultRow[],
|
||||||
|
rowDims: string[],
|
||||||
|
colDims: string[],
|
||||||
|
measures: string[],
|
||||||
|
): PivotedRow[] {
|
||||||
|
const map = new Map<string, PivotedRow>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
|
||||||
|
let pivoted = map.get(rowKey);
|
||||||
|
if (!pivoted) {
|
||||||
|
const rowKeys: Record<string, string> = {};
|
||||||
|
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
|
||||||
|
pivoted = { rowKeys, cells: {} };
|
||||||
|
map.set(rowKey, pivoted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colKey = colDims.length > 0
|
||||||
|
? colDims.map((d) => row.keys[d] || "").join("\0")
|
||||||
|
: "__all__";
|
||||||
|
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
|
||||||
|
for (const m of measures) {
|
||||||
|
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupNode {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
pivotedRows: PivotedRow[];
|
||||||
|
children: GroupNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
|
||||||
|
if (depth >= rowDims.length) return [];
|
||||||
|
const dim = rowDims[depth];
|
||||||
|
const map = new Map<string, PivotedRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.rowKeys[dim] || "";
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const groups: GroupNode[] = [];
|
||||||
|
for (const [key, groupRows] of map) {
|
||||||
|
groups.push({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
pivotedRows: groupRows,
|
||||||
|
children: buildGroups(groupRows, rowDims, depth + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSubtotals(
|
||||||
|
rows: PivotedRow[],
|
||||||
|
measures: string[],
|
||||||
|
colValues: string[],
|
||||||
|
): Record<string, Record<string, number>> {
|
||||||
|
const result: Record<string, Record<string, number>> = {};
|
||||||
|
for (const colVal of colValues) {
|
||||||
|
result[colVal] = {};
|
||||||
|
for (const m of measures) {
|
||||||
|
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === null ? true : stored === "top";
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSubtotals = () => {
|
||||||
|
setSubtotalsOnTop((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowDims = config.rows;
|
||||||
|
const colDims = config.columns;
|
||||||
|
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
|
||||||
|
const measures = config.values;
|
||||||
|
|
||||||
|
// Display label for a composite column key (joined with \0)
|
||||||
|
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
|
||||||
|
|
||||||
|
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
|
||||||
|
const pivotedRows = useMemo(
|
||||||
|
() => pivotRows(result.rows, rowDims, colDims, measures),
|
||||||
|
[result.rows, rowDims, colDims, measures],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pivotedRows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.pivot.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
|
||||||
|
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
|
||||||
|
|
||||||
|
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
|
||||||
|
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
{rowDims.length > 1 && (
|
||||||
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{rowDims.map((dim) => (
|
||||||
|
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{fieldLabel(dim)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{colValues.map((colVal) =>
|
||||||
|
measures.map((m) => (
|
||||||
|
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
|
||||||
|
</th>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rowDims.length === 0 ? (
|
||||||
|
<tr className="border-b border-[var(--border)]/50">
|
||||||
|
{colValues.map((colVal) =>
|
||||||
|
measures.map((m) => (
|
||||||
|
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<GroupRows
|
||||||
|
key={group.key}
|
||||||
|
group={group}
|
||||||
|
colValues={colValues}
|
||||||
|
measures={measures}
|
||||||
|
rowDims={rowDims}
|
||||||
|
depth={0}
|
||||||
|
subtotalsOnTop={subtotalsOnTop}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{/* Grand total */}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||||
|
<td colSpan={rowDims.length || 1} className="px-3 py-3">
|
||||||
|
{t("reports.pivot.total")}
|
||||||
|
</td>
|
||||||
|
{colValues.map((colVal) =>
|
||||||
|
measures.map((m) => (
|
||||||
|
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupRows({
|
||||||
|
group,
|
||||||
|
colValues,
|
||||||
|
measures,
|
||||||
|
rowDims,
|
||||||
|
depth,
|
||||||
|
subtotalsOnTop,
|
||||||
|
}: {
|
||||||
|
group: GroupNode;
|
||||||
|
colValues: string[];
|
||||||
|
measures: string[];
|
||||||
|
rowDims: string[];
|
||||||
|
depth: number;
|
||||||
|
subtotalsOnTop: boolean;
|
||||||
|
}) {
|
||||||
|
const isLeafLevel = depth === rowDims.length - 1;
|
||||||
|
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
|
||||||
|
|
||||||
|
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
|
||||||
|
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
|
||||||
|
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
||||||
|
{group.label}
|
||||||
|
</td>
|
||||||
|
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
|
||||||
|
{colValues.map((colVal) =>
|
||||||
|
measures.map((m) => (
|
||||||
|
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(subtotals[colVal]?.[m] || 0)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (isLeafLevel) {
|
||||||
|
// Render one table row per pivoted row (already deduplicated by row keys)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{group.pivotedRows.map((pRow, i) => (
|
||||||
|
<tr key={i} className="border-b border-[var(--border)]/50">
|
||||||
|
{rowDims.map((dim, di) => (
|
||||||
|
<td
|
||||||
|
key={dim}
|
||||||
|
className="px-3 py-1.5"
|
||||||
|
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
|
||||||
|
>
|
||||||
|
{pRow.rowKeys[dim] || ""}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{colValues.map((colVal) =>
|
||||||
|
measures.map((m) => (
|
||||||
|
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const childContent = group.children.map((child) => (
|
||||||
|
<GroupRows
|
||||||
|
key={child.key}
|
||||||
|
group={child}
|
||||||
|
colValues={colValues}
|
||||||
|
measures={measures}
|
||||||
|
rowDims={rowDims}
|
||||||
|
depth={depth + 1}
|
||||||
|
subtotalsOnTop={subtotalsOnTop}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{subtotalsOnTop && subtotalRow}
|
||||||
|
{childContent}
|
||||||
|
{!subtotalsOnTop && subtotalRow}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
|
LabelList,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { MonthlyTrendItem } from "../../shared/types";
|
import type { MonthlyTrendItem } from "../../shared/types";
|
||||||
|
|
||||||
|
|
@ -21,9 +22,10 @@ function formatMonth(month: string): string {
|
||||||
|
|
||||||
interface MonthlyTrendsChartProps {
|
interface MonthlyTrendsChartProps {
|
||||||
data: MonthlyTrendItem[];
|
data: MonthlyTrendItem[];
|
||||||
|
showAmounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
|
|
@ -80,7 +82,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||||
stroke="var(--positive)"
|
stroke="var(--positive)"
|
||||||
fill="url(#gradientIncome)"
|
fill="url(#gradientIncome)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
>
|
||||||
|
{showAmounts && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="income"
|
||||||
|
position="top"
|
||||||
|
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||||
|
style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Area>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="expenses"
|
dataKey="expenses"
|
||||||
|
|
@ -88,7 +99,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||||
stroke="var(--negative)"
|
stroke="var(--negative)"
|
||||||
fill="url(#gradientExpenses)"
|
fill="url(#gradientExpenses)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
>
|
||||||
|
{showAmounts && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="expenses"
|
||||||
|
position="bottom"
|
||||||
|
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||||
|
style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Area>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
77
src/components/reports/MonthlyTrendsTable.tsx
Normal file
77
src/components/reports/MonthlyTrendsTable.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { MonthlyTrendItem } from "../../shared/types";
|
||||||
|
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
function formatMonth(month: string): string {
|
||||||
|
const [year, m] = month.split("-");
|
||||||
|
const date = new Date(Number(year), Number(m) - 1);
|
||||||
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyTrendsTableProps {
|
||||||
|
data: MonthlyTrendItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = data.reduce(
|
||||||
|
(acc, row) => ({ income: acc.income + row.income, expenses: acc.expenses + row.expenses }),
|
||||||
|
{ income: 0, expenses: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("reports.pivot.month")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("dashboard.income")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("dashboard.expenses")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||||
|
{t("dashboard.net")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<tr key={row.month} className="border-b border-[var(--border)]/50">
|
||||||
|
<td className="px-3 py-1.5">{formatMonth(row.month)}</td>
|
||||||
|
<td className="text-right px-3 py-1.5 text-[var(--positive)]">{cadFormatter(row.income)}</td>
|
||||||
|
<td className="text-right px-3 py-1.5 text-[var(--negative)]">{cadFormatter(row.expenses)}</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${row.income - row.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
|
||||||
|
{cadFormatter(row.income - row.expenses)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||||
|
<td className="px-3 py-3">{t("common.total")}</td>
|
||||||
|
<td className="text-right px-3 py-3 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
|
||||||
|
<td className="text-right px-3 py-3 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
|
||||||
|
<td className={`text-right px-3 py-3 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
|
||||||
|
{cadFormatter(totals.income - totals.expenses)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/reports/ReportFilterPanel.tsx
Normal file
135
src/components/reports/ReportFilterPanel.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Filter, Search } from "lucide-react";
|
||||||
|
import type { ImportSource } from "../../shared/types";
|
||||||
|
|
||||||
|
interface ReportFilterPanelProps {
|
||||||
|
categories: { name: string; color: string }[];
|
||||||
|
hiddenCategories: Set<string>;
|
||||||
|
onToggleHidden: (name: string) => void;
|
||||||
|
onShowAll: () => void;
|
||||||
|
sources: ImportSource[];
|
||||||
|
selectedSourceId: number | null;
|
||||||
|
onSourceChange: (id: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportFilterPanel({
|
||||||
|
categories,
|
||||||
|
hiddenCategories,
|
||||||
|
onToggleHidden,
|
||||||
|
onShowAll,
|
||||||
|
sources,
|
||||||
|
selectedSourceId,
|
||||||
|
onSourceChange,
|
||||||
|
}: ReportFilterPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
const allVisible = hiddenCategories.size === 0;
|
||||||
|
const allHidden = hiddenCategories.size === categories.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
|
||||||
|
{/* Source filter */}
|
||||||
|
{sources.length > 1 && (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
|
||||||
|
<Filter size={14} className="text-[var(--muted-foreground)]" />
|
||||||
|
{t("transactions.table.source")}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[var(--border)] px-2 py-2">
|
||||||
|
<select
|
||||||
|
value={selectedSourceId ?? ""}
|
||||||
|
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<option value="">{t("transactions.filters.allSources")}</option>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<Filter size={14} className="text-[var(--muted-foreground)]" />
|
||||||
|
{t("reports.filters.title")}
|
||||||
|
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
|
||||||
|
{categories.length - hiddenCategories.size}/{categories.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="border-t border-[var(--border)]">
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("reports.filters.search")}
|
||||||
|
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 pb-1 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onShowAll}
|
||||||
|
disabled={allVisible}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("reports.filters.all")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
|
||||||
|
disabled={allHidden}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("reports.filters.none")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
|
||||||
|
{filtered.map((cat) => {
|
||||||
|
const visible = !hiddenCategories.has(cat.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={cat.name}
|
||||||
|
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
onChange={() => onToggleHidden(cat.name)}
|
||||||
|
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: cat.color }}
|
||||||
|
/>
|
||||||
|
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/settings/LogViewerCard.tsx
Normal file
116
src/components/settings/LogViewerCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
|
||||||
|
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
|
||||||
|
|
||||||
|
type Filter = "all" | LogLevel;
|
||||||
|
|
||||||
|
export default function LogViewerCard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
|
||||||
|
|
||||||
|
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filtered.length]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const text = filtered
|
||||||
|
.map((l) => {
|
||||||
|
const time = new Date(l.timestamp).toLocaleTimeString();
|
||||||
|
return `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelColor: Record<LogLevel, string> = {
|
||||||
|
info: "text-[var(--muted-foreground)]",
|
||||||
|
warn: "text-amber-500",
|
||||||
|
error: "text-[var(--negative)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: { value: Filter; label: string }[] = [
|
||||||
|
{ value: "all", label: t("settings.logs.filterAll") },
|
||||||
|
{ value: "error", label: "Error" },
|
||||||
|
{ value: "warn", label: "Warn" },
|
||||||
|
{ value: "info", label: "Info" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ScrollText size={18} />
|
||||||
|
{t("settings.logs.title")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={filtered.length === 0}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? t("settings.logs.copied") : t("settings.logs.copy")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t("settings.logs.clear")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{filters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setFilter(f.value)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
filter === f.value
|
||||||
|
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="h-64 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 font-mono text-xs space-y-0.5"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted-foreground)] text-center py-8">
|
||||||
|
{t("settings.logs.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((entry, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||||
|
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 uppercase font-semibold w-12 ${levelColor[entry.level]}`}>
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--foreground)] break-all">{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/shared/ErrorBoundary.tsx
Normal file
34
src/components/shared/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import ErrorPage from "./ErrorPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <ErrorPage error={this.state.error ?? undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/components/shared/ErrorPage.tsx
Normal file
123
src/components/shared/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||||
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
|
||||||
|
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCheckUpdate = async () => {
|
||||||
|
setUpdateStatus("checking");
|
||||||
|
setUpdateError(null);
|
||||||
|
try {
|
||||||
|
const update = await check();
|
||||||
|
if (update) {
|
||||||
|
setUpdateStatus("available");
|
||||||
|
setUpdateVersion(update.version);
|
||||||
|
} else {
|
||||||
|
setUpdateStatus("upToDate");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setUpdateStatus("error");
|
||||||
|
setUpdateError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-[var(--background)] p-4">
|
||||||
|
<div className="max-w-md w-full space-y-6 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-16 w-16 text-[var(--destructive)]" />
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--foreground)]">
|
||||||
|
{t("error.title")}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
|
||||||
|
{showDetails ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
{showDetails && (
|
||||||
|
<pre className="mt-2 p-3 bg-[var(--muted)] rounded-md text-xs text-left text-[var(--muted-foreground)] overflow-auto max-h-40">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{t("error.refresh")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCheckUpdate}
|
||||||
|
disabled={updateStatus === "checking"}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{updateStatus === "checking" ? t("common.loading") : t("error.checkUpdate")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{updateStatus === "available" && updateVersion && (
|
||||||
|
<p className="text-sm text-[var(--primary)]">
|
||||||
|
{t("error.updateAvailable", { version: updateVersion })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updateStatus === "upToDate" && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("error.upToDate")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updateStatus === "error" && updateError && (
|
||||||
|
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-[var(--border)]">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||||
|
{t("error.contactUs")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<a
|
||||||
|
href="mailto:lacompagniemaximus@protonmail.com"
|
||||||
|
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t("error.contactEmail")} lacompagniemaximus@protonmail.com
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<Bug className="h-4 w-4" />
|
||||||
|
{t("error.reportIssue")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X, Loader2, ArrowUp, ArrowDown, Eye, EyeOff } from "lucide-react";
|
||||||
import { getTransactionsByCategory } from "../../services/dashboardService";
|
import { getTransactionsByCategory } from "../../services/dashboardService";
|
||||||
import type { TransactionRow } from "../../shared/types";
|
import type { TransactionRow } from "../../shared/types";
|
||||||
|
|
||||||
|
|
@ -10,6 +10,9 @@ const cadFormatter = new Intl.NumberFormat("en-CA", {
|
||||||
currency: "CAD",
|
currency: "CAD",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type SortColumn = "date" | "description" | "amount";
|
||||||
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
interface TransactionDetailModalProps {
|
interface TransactionDetailModalProps {
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
|
|
@ -31,6 +34,9 @@ export default function TransactionDetailModal({
|
||||||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sortColumn, setSortColumn] = useState<SortColumn>("date");
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||||
|
const [showAmounts, setShowAmounts] = useState(true);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -57,8 +63,42 @@ export default function TransactionDetailModal({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSort = (column: SortColumn) => {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection(column === "description" ? "asc" : "desc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
const sorted = [...rows];
|
||||||
|
const dir = sortDirection === "asc" ? 1 : -1;
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
switch (sortColumn) {
|
||||||
|
case "date":
|
||||||
|
return (a.date < b.date ? -1 : a.date > b.date ? 1 : 0) * dir;
|
||||||
|
case "description":
|
||||||
|
return a.description.localeCompare(b.description) * dir;
|
||||||
|
case "amount":
|
||||||
|
return (a.amount - b.amount) * dir;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [rows, sortColumn, sortDirection]);
|
||||||
|
|
||||||
const total = rows.reduce((sum, r) => sum + r.amount, 0);
|
const total = rows.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
|
||||||
|
const SortIcon = ({ column }: { column: SortColumn }) => {
|
||||||
|
if (sortColumn !== column) return null;
|
||||||
|
return sortDirection === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const thClass = "px-6 py-2 font-medium cursor-pointer select-none hover:text-[var(--foreground)] transition-colors";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
||||||
|
|
@ -77,12 +117,21 @@ export default function TransactionDetailModal({
|
||||||
({rows.length} {t("charts.transactions")})
|
({rows.length} {t("charts.transactions")})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={onClose}
|
<button
|
||||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
onClick={() => setShowAmounts((v) => !v)}
|
||||||
>
|
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors text-[var(--muted-foreground)]"
|
||||||
<X size={18} />
|
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||||
</button>
|
>
|
||||||
|
{showAmounts ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
|
|
@ -107,34 +156,64 @@ export default function TransactionDetailModal({
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--border)] text-[var(--muted-foreground)]">
|
<tr className="border-b border-[var(--border)] text-[var(--muted-foreground)]">
|
||||||
<th className="text-left px-6 py-2 font-medium">{t("transactions.date")}</th>
|
<th
|
||||||
<th className="text-left px-6 py-2 font-medium">{t("transactions.description")}</th>
|
className={`text-left ${thClass}`}
|
||||||
<th className="text-right px-6 py-2 font-medium">{t("transactions.amount")}</th>
|
onClick={() => handleSort("date")}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("transactions.date")}
|
||||||
|
<SortIcon column="date" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className={`text-left ${thClass}`}
|
||||||
|
onClick={() => handleSort("description")}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("transactions.description")}
|
||||||
|
<SortIcon column="description" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
{showAmounts && (
|
||||||
|
<th
|
||||||
|
className={`text-right ${thClass}`}
|
||||||
|
onClick={() => handleSort("amount")}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{t("transactions.amount")}
|
||||||
|
<SortIcon column="amount" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{sortedRows.map((row) => (
|
||||||
<tr key={row.id} className="border-b border-[var(--border)] hover:bg-[var(--muted)]">
|
<tr key={row.id} className="border-b border-[var(--border)] hover:bg-[var(--muted)]">
|
||||||
<td className="px-6 py-2 whitespace-nowrap">{row.date}</td>
|
<td className="px-6 py-2 whitespace-nowrap">{row.date}</td>
|
||||||
<td className="px-6 py-2 truncate max-w-[300px]">{row.description}</td>
|
<td className="px-6 py-2 truncate max-w-[300px]">{row.description}</td>
|
||||||
<td className={`px-6 py-2 text-right whitespace-nowrap font-medium ${
|
{showAmounts && (
|
||||||
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
<td className={`px-6 py-2 text-right whitespace-nowrap font-medium ${
|
||||||
}`}>
|
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||||
{cadFormatter.format(row.amount)}
|
}`}>
|
||||||
</td>
|
{cadFormatter.format(row.amount)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
{showAmounts && (
|
||||||
<tr className="font-semibold">
|
<tfoot>
|
||||||
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
|
<tr className="font-semibold">
|
||||||
<td className={`px-6 py-3 text-right ${
|
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
|
||||||
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
<td className={`px-6 py-3 text-right ${
|
||||||
}`}>
|
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||||
{cadFormatter.format(total)}
|
}`}>
|
||||||
</td>
|
{cadFormatter.format(total)}
|
||||||
</tr>
|
</td>
|
||||||
</tfoot>
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export default function TransactionTable({
|
||||||
onClick={() => setSplitRow(row)}
|
onClick={() => setSplitRow(row)}
|
||||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||||
row.is_split
|
row.is_split
|
||||||
? "text-[var(--primary)]"
|
? "text-orange-500"
|
||||||
: "text-[var(--muted-foreground)]"
|
: "text-[var(--muted-foreground)]"
|
||||||
}`}
|
}`}
|
||||||
title={t("transactions.splitAdjustment")}
|
title={t("transactions.splitAdjustment")}
|
||||||
|
|
@ -196,7 +196,7 @@ export default function TransactionTable({
|
||||||
onClick={() => toggleNotes(row)}
|
onClick={() => toggleNotes(row)}
|
||||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
|
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
|
||||||
row.notes
|
row.notes
|
||||||
? "text-[var(--primary)]"
|
? "text-orange-500"
|
||||||
: "text-[var(--muted-foreground)]"
|
: "text-[var(--muted-foreground)]"
|
||||||
}`}
|
}`}
|
||||||
title={t("transactions.notes.placeholder")}
|
title={t("transactions.notes.placeholder")}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getAllActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
|
getActualTotalsForYear,
|
||||||
upsertBudgetEntry,
|
upsertBudgetEntry,
|
||||||
upsertBudgetEntriesForYear,
|
upsertBudgetEntriesForYear,
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
|
|
@ -72,9 +73,10 @@ export function useBudget() {
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [allCategories, entries, templates] = await Promise.all([
|
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||||
getAllActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
|
getActualTotalsForYear(year - 1),
|
||||||
getAllTemplates(),
|
getAllTemplates(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -87,6 +89,13 @@ export function useBudget() {
|
||||||
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a map for previous year actuals: categoryId -> annual actual total
|
||||||
|
// Amounts are already signed (expenses negative, income positive) — stored as-is.
|
||||||
|
const prevYearTotalMap = new Map<number, number>();
|
||||||
|
for (const a of prevYearActuals) {
|
||||||
|
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: build months array from entryMap
|
// Helper: build months array from entryMap
|
||||||
const buildMonths = (catId: number) => {
|
const buildMonths = (catId: number) => {
|
||||||
const monthMap = entryMap.get(catId);
|
const monthMap = entryMap.get(catId);
|
||||||
|
|
@ -97,7 +106,8 @@ export function useBudget() {
|
||||||
months.push(val);
|
months.push(val);
|
||||||
annual += val;
|
annual += val;
|
||||||
}
|
}
|
||||||
return { months, annual };
|
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
|
||||||
|
return { months, annual, previousYearTotal };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Index categories by id and group children by parent_id
|
// Index categories by id and group children by parent_id
|
||||||
|
|
@ -112,15 +122,104 @@ export function useBudget() {
|
||||||
|
|
||||||
const rows: BudgetYearRow[] = [];
|
const rows: BudgetYearRow[] = [];
|
||||||
|
|
||||||
|
// Build rows for an intermediate parent (level 1 or 2 with children)
|
||||||
|
function buildLevel2Group(cat: typeof allCategories[0], grandparentId: number): BudgetYearRow[] {
|
||||||
|
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||||
|
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||||
|
// Leaf at depth 2
|
||||||
|
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||||
|
return [{
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: cat.name,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: grandparentId,
|
||||||
|
is_parent: false,
|
||||||
|
depth: 2,
|
||||||
|
months,
|
||||||
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (grandchildren.length === 0 && !cat.is_inputable) {
|
||||||
|
// Also check if it has non-inputable intermediate children with their own children
|
||||||
|
// This shouldn't happen at depth 3 (max 3 levels), but handle gracefully
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcRows: BudgetYearRow[] = [];
|
||||||
|
if (cat.is_inputable) {
|
||||||
|
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||||
|
gcRows.push({
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: `${cat.name} (direct)`,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: cat.id,
|
||||||
|
is_parent: false,
|
||||||
|
depth: 2,
|
||||||
|
months,
|
||||||
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const gc of grandchildren) {
|
||||||
|
const { months, annual, previousYearTotal } = buildMonths(gc.id);
|
||||||
|
gcRows.push({
|
||||||
|
category_id: gc.id,
|
||||||
|
category_name: gc.name,
|
||||||
|
category_color: gc.color || cat.color || "#9ca3af",
|
||||||
|
category_type: gc.type,
|
||||||
|
parent_id: cat.id,
|
||||||
|
is_parent: false,
|
||||||
|
depth: 2,
|
||||||
|
months,
|
||||||
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (gcRows.length === 0) return [];
|
||||||
|
|
||||||
|
// Build intermediate subtotal
|
||||||
|
const subMonths = Array(12).fill(0) as number[];
|
||||||
|
let subAnnual = 0;
|
||||||
|
let subPrevYear = 0;
|
||||||
|
for (const cr of gcRows) {
|
||||||
|
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
|
||||||
|
subAnnual += cr.annual;
|
||||||
|
subPrevYear += cr.previousYearTotal;
|
||||||
|
}
|
||||||
|
const subtotal: BudgetYearRow = {
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: cat.name,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: grandparentId,
|
||||||
|
is_parent: true,
|
||||||
|
depth: 1,
|
||||||
|
months: subMonths,
|
||||||
|
annual: subAnnual,
|
||||||
|
previousYearTotal: subPrevYear,
|
||||||
|
};
|
||||||
|
gcRows.sort((a, b) => {
|
||||||
|
if (a.category_id === cat.id) return -1;
|
||||||
|
if (b.category_id === cat.id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
|
return [subtotal, ...gcRows];
|
||||||
|
}
|
||||||
|
|
||||||
// Identify top-level parents and standalone leaves
|
// Identify top-level parents and standalone leaves
|
||||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
const topLevel = allCategories.filter((c) => !c.parent_id);
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
|
const inputableChildren = children.filter((c) => c.is_inputable);
|
||||||
|
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
|
||||||
|
|
||||||
if (children.length === 0 && cat.is_inputable) {
|
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||||
// Standalone leaf (no children) — regular editable row
|
// Standalone leaf (no children) — regular editable row
|
||||||
const { months, annual } = buildMonths(cat.id);
|
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||||
rows.push({
|
rows.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
|
|
@ -128,48 +227,70 @@ export function useBudget() {
|
||||||
category_type: cat.type,
|
category_type: cat.type,
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
is_parent: false,
|
is_parent: false,
|
||||||
|
depth: 0,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
});
|
});
|
||||||
} else if (children.length > 0) {
|
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||||
// Parent with children — build child rows first, then parent subtotal
|
const allChildRows: BudgetYearRow[] = [];
|
||||||
const childRows: BudgetYearRow[] = [];
|
|
||||||
|
|
||||||
// If parent is also inputable, create a "(direct)" fake-child row
|
// If parent is also inputable, create a "(direct)" fake-child row
|
||||||
if (cat.is_inputable) {
|
if (cat.is_inputable) {
|
||||||
const { months, annual } = buildMonths(cat.id);
|
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||||
childRows.push({
|
allChildRows.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: `${cat.name} (direct)`,
|
category_name: `${cat.name} (direct)`,
|
||||||
category_color: cat.color || "#9ca3af",
|
category_color: cat.color || "#9ca3af",
|
||||||
category_type: cat.type,
|
category_type: cat.type,
|
||||||
parent_id: cat.id,
|
parent_id: cat.id,
|
||||||
is_parent: false,
|
is_parent: false,
|
||||||
|
depth: 1,
|
||||||
months,
|
months,
|
||||||
annual,
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of inputableChildren) {
|
||||||
const { months, annual } = buildMonths(child.id);
|
const grandchildren = childrenByParent.get(child.id) || [];
|
||||||
childRows.push({
|
if (grandchildren.length === 0) {
|
||||||
category_id: child.id,
|
// Simple leaf at depth 1
|
||||||
category_name: child.name,
|
const { months, annual, previousYearTotal } = buildMonths(child.id);
|
||||||
category_color: child.color || cat.color || "#9ca3af",
|
allChildRows.push({
|
||||||
category_type: child.type,
|
category_id: child.id,
|
||||||
parent_id: cat.id,
|
category_name: child.name,
|
||||||
is_parent: false,
|
category_color: child.color || cat.color || "#9ca3af",
|
||||||
months,
|
category_type: child.type,
|
||||||
annual,
|
parent_id: cat.id,
|
||||||
});
|
is_parent: false,
|
||||||
|
depth: 1,
|
||||||
|
months,
|
||||||
|
annual,
|
||||||
|
previousYearTotal,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Intermediate parent at depth 1 with grandchildren
|
||||||
|
allChildRows.push(...buildLevel2Group(child, cat.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent subtotal row: sum of all children (+ direct if inputable)
|
// Non-inputable intermediate parents
|
||||||
|
for (const ip of intermediateParents) {
|
||||||
|
allChildRows.push(...buildLevel2Group(ip, cat.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allChildRows.length === 0) continue;
|
||||||
|
|
||||||
|
// Parent subtotal row: sum of leaf rows only (avoid double-counting)
|
||||||
|
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
||||||
const parentMonths = Array(12).fill(0) as number[];
|
const parentMonths = Array(12).fill(0) as number[];
|
||||||
let parentAnnual = 0;
|
let parentAnnual = 0;
|
||||||
for (const cr of childRows) {
|
let parentPrevYear = 0;
|
||||||
|
for (const cr of leafRows) {
|
||||||
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
||||||
parentAnnual += cr.annual;
|
parentAnnual += cr.annual;
|
||||||
|
parentPrevYear += cr.previousYearTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|
@ -179,32 +300,44 @@ export function useBudget() {
|
||||||
category_type: cat.type,
|
category_type: cat.type,
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
is_parent: true,
|
is_parent: true,
|
||||||
|
depth: 0,
|
||||||
months: parentMonths,
|
months: parentMonths,
|
||||||
annual: parentAnnual,
|
annual: parentAnnual,
|
||||||
|
previousYearTotal: parentPrevYear,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort children alphabetically, but keep "(direct)" first
|
// Sort children alphabetically, but keep "(direct)" first
|
||||||
childRows.sort((a, b) => {
|
allChildRows.sort((a, b) => {
|
||||||
if (a.category_id === cat.id) return -1;
|
if (a.category_id === cat.id && !a.is_parent) return -1;
|
||||||
if (b.category_id === cat.id) return 1;
|
if (b.category_id === cat.id && !b.is_parent) return 1;
|
||||||
return a.category_name.localeCompare(b.category_name);
|
return a.category_name.localeCompare(b.category_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
rows.push(...childRows);
|
rows.push(...allChildRows);
|
||||||
}
|
}
|
||||||
// else: non-inputable parent with no inputable children — skip
|
// else: non-inputable parent with no inputable children — skip
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by type, then within each type: parent rows first (with children following), then standalone
|
// Sort by type, then within each type: keep hierarchy groups together
|
||||||
|
function getTopGroupId(r: BudgetYearRow): number {
|
||||||
|
if ((r.depth ?? 0) === 0) return r.category_id;
|
||||||
|
if (r.is_parent && r.parent_id === null) return r.category_id;
|
||||||
|
let pid = r.parent_id;
|
||||||
|
while (pid !== null) {
|
||||||
|
const pCat = catById.get(pid);
|
||||||
|
if (!pCat || !pCat.parent_id) return pid;
|
||||||
|
pid = pCat.parent_id;
|
||||||
|
}
|
||||||
|
return r.category_id;
|
||||||
|
}
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
if (typeA !== typeB) return typeA - typeB;
|
if (typeA !== typeB) return typeA - typeB;
|
||||||
// Within same type, keep parent+children groups together
|
const groupA = getTopGroupId(a);
|
||||||
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id);
|
const groupB = getTopGroupId(b);
|
||||||
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id);
|
|
||||||
if (groupA !== groupB) {
|
if (groupA !== groupB) {
|
||||||
// Find the sort_order of the group's parent category
|
|
||||||
const catA = catById.get(groupA);
|
const catA = catById.get(groupA);
|
||||||
const catB = catById.get(groupB);
|
const catB = catById.get(groupB);
|
||||||
const orderA = catA?.sort_order ?? 999;
|
const orderA = catA?.sort_order ?? 999;
|
||||||
|
|
@ -212,9 +345,9 @@ export function useBudget() {
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
|
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
|
||||||
}
|
}
|
||||||
// Same group: parent row first, then children
|
// Same group: sort by depth, then parent before children at same depth
|
||||||
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
|
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
|
||||||
// Children: "(direct)" first, then alphabetical
|
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
|
||||||
if (a.parent_id && a.category_id === a.parent_id) return -1;
|
if (a.parent_id && a.category_id === a.parent_id) return -1;
|
||||||
if (b.parent_id && b.category_id === b.parent_id) return 1;
|
if (b.parent_id && b.category_id === b.parent_id) return 1;
|
||||||
return a.category_name.localeCompare(b.category_name);
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,15 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
|
||||||
|
|
||||||
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
|
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
|
||||||
const result: CategoryTreeNode[] = [];
|
const result: CategoryTreeNode[] = [];
|
||||||
for (const node of tree) {
|
function recurse(nodes: CategoryTreeNode[]) {
|
||||||
result.push(node);
|
for (const node of nodes) {
|
||||||
for (const child of node.children) {
|
result.push(node);
|
||||||
result.push(child);
|
if (node.children.length > 0) {
|
||||||
|
recurse(node.children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recurse(tree);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,23 +266,19 @@ export function useCategories() {
|
||||||
});
|
});
|
||||||
const newTree = state.tree.map(cloneNode);
|
const newTree = state.tree.map(cloneNode);
|
||||||
|
|
||||||
// Find and remove the category from its current position
|
// Recursively find and remove the category from its current position
|
||||||
let movedNode: CategoryTreeNode | null = null;
|
function removeFromList(list: CategoryTreeNode[]): CategoryTreeNode | null {
|
||||||
|
const idx = list.findIndex((n) => n.id === categoryId);
|
||||||
// Search in roots
|
if (idx !== -1) {
|
||||||
const rootIdx = newTree.findIndex((n) => n.id === categoryId);
|
return list.splice(idx, 1)[0];
|
||||||
if (rootIdx !== -1) {
|
|
||||||
movedNode = newTree.splice(rootIdx, 1)[0];
|
|
||||||
} else {
|
|
||||||
// Search in children
|
|
||||||
for (const parent of newTree) {
|
|
||||||
const childIdx = parent.children.findIndex((c) => c.id === categoryId);
|
|
||||||
if (childIdx !== -1) {
|
|
||||||
movedNode = parent.children.splice(childIdx, 1)[0];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (const node of list) {
|
||||||
|
const found = removeFromList(node.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
const movedNode = removeFromList(newTree);
|
||||||
|
|
||||||
if (!movedNode) return;
|
if (!movedNode) return;
|
||||||
|
|
||||||
|
|
@ -290,7 +289,16 @@ export function useCategories() {
|
||||||
if (newParentId === null) {
|
if (newParentId === null) {
|
||||||
newTree.splice(newIndex, 0, movedNode);
|
newTree.splice(newIndex, 0, movedNode);
|
||||||
} else {
|
} else {
|
||||||
const newParent = newTree.find((n) => n.id === newParentId);
|
// Find parent anywhere in the tree
|
||||||
|
function findNode(list: CategoryTreeNode[], id: number): CategoryTreeNode | null {
|
||||||
|
for (const n of list) {
|
||||||
|
if (n.id === id) return n;
|
||||||
|
const found = findNode(n.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const newParent = findNode(newTree, newParentId);
|
||||||
if (!newParent) return;
|
if (!newParent) return;
|
||||||
newParent.children.splice(newIndex, 0, movedNode);
|
newParent.children.splice(newIndex, 0, movedNode);
|
||||||
}
|
}
|
||||||
|
|
@ -298,24 +306,16 @@ export function useCategories() {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
dispatch({ type: "SET_TREE", payload: newTree });
|
dispatch({ type: "SET_TREE", payload: newTree });
|
||||||
|
|
||||||
// Compute batch updates for affected sibling groups
|
// Compute batch updates for all nodes in the tree (3 levels)
|
||||||
const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
|
const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
|
||||||
|
|
||||||
// Collect all affected sibling groups
|
function collectUpdates(nodes: CategoryTreeNode[], parentId: number | null) {
|
||||||
const affectedGroups = new Set<number | null>();
|
nodes.forEach((n, i) => {
|
||||||
affectedGroups.add(newParentId);
|
updates.push({ id: n.id, sort_order: i + 1, parent_id: parentId });
|
||||||
// Also include the old parent group (category may have moved away)
|
collectUpdates(n.children, n.id);
|
||||||
// We recompute all roots and all children groups to be safe
|
|
||||||
// Roots
|
|
||||||
newTree.forEach((n, i) => {
|
|
||||||
updates.push({ id: n.id, sort_order: i + 1, parent_id: null });
|
|
||||||
});
|
|
||||||
// Children
|
|
||||||
for (const parent of newTree) {
|
|
||||||
parent.children.forEach((c, i) => {
|
|
||||||
updates.push({ id: c.id, sort_order: i + 1, parent_id: parent.id });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
collectUpdates(newTree, null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateCategorySortOrders(updates);
|
await updateCategorySortOrders(updates);
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,27 @@ import type {
|
||||||
DashboardPeriod,
|
DashboardPeriod,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
RecentTransaction,
|
CategoryOverTimeData,
|
||||||
|
BudgetVsActualRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getDashboardSummary,
|
getDashboardSummary,
|
||||||
getExpensesByCategory,
|
getExpensesByCategory,
|
||||||
getRecentTransactions,
|
|
||||||
} from "../services/dashboardService";
|
} from "../services/dashboardService";
|
||||||
|
import { getCategoryOverTime } from "../services/reportService";
|
||||||
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
recentTransactions: RecentTransaction[];
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
budgetYear: number;
|
||||||
|
budgetMonth: number;
|
||||||
|
customDateFrom: string;
|
||||||
|
customDateTo: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -28,16 +36,28 @@ type DashboardAction =
|
||||||
payload: {
|
payload: {
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
recentTransactions: RecentTransaction[];
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod };
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||||
|
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
recentTransactions: [],
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
period: "month",
|
budgetVsActual: [],
|
||||||
|
period: "year",
|
||||||
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||||
|
customDateFrom: yearStartStr,
|
||||||
|
customDateTo: todayStr,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -53,66 +73,47 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
...state,
|
...state,
|
||||||
summary: action.payload.summary,
|
summary: action.payload.summary,
|
||||||
categoryBreakdown: action.payload.categoryBreakdown,
|
categoryBreakdown: action.payload.categoryBreakdown,
|
||||||
recentTransactions: action.payload.recentTransactions,
|
categoryOverTime: action.payload.categoryOverTime,
|
||||||
|
budgetVsActual: action.payload.budgetVsActual,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
|
case "SET_BUDGET_MONTH":
|
||||||
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
|
case "SET_CUSTOM_DATES":
|
||||||
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
|
||||||
if (period === "all") return {};
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
|
|
||||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
let from: Date;
|
|
||||||
switch (period) {
|
|
||||||
case "month":
|
|
||||||
from = new Date(year, month, 1);
|
|
||||||
break;
|
|
||||||
case "3months":
|
|
||||||
from = new Date(year, month - 2, 1);
|
|
||||||
break;
|
|
||||||
case "6months":
|
|
||||||
from = new Date(year, month - 5, 1);
|
|
||||||
break;
|
|
||||||
case "12months":
|
|
||||||
from = new Date(year, month - 11, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDashboard() {
|
export function useDashboard() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetchData = useCallback(async (period: DashboardPeriod) => {
|
const fetchData = useCallback(async (
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customFrom: string | undefined,
|
||||||
|
customTo: string | undefined,
|
||||||
|
bYear: number,
|
||||||
|
bMonth: number,
|
||||||
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
||||||
getDashboardSummary(dateFrom, dateTo),
|
getDashboardSummary(dateFrom, dateTo),
|
||||||
getExpensesByCategory(dateFrom, dateTo),
|
getExpensesByCategory(dateFrom, dateTo),
|
||||||
getRecentTransactions(10),
|
getCategoryOverTime(dateFrom, dateTo),
|
||||||
|
getBudgetVsActualData(bYear, bMonth),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
|
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -123,12 +124,20 @@ export function useDashboard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.period);
|
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
||||||
}, [state.period, fetchData]);
|
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setPeriod };
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||||
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,28 @@ import type {
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
BudgetVsActualRow,
|
BudgetVsActualRow,
|
||||||
|
PivotConfig,
|
||||||
|
PivotResult,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||||
import { getExpensesByCategory } from "../services/dashboardService";
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
customDateFrom: string;
|
||||||
|
customDateTo: string;
|
||||||
|
sourceId: number | null;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categorySpending: CategoryBreakdownItem[];
|
categorySpending: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
budgetYear: number;
|
budgetYear: number;
|
||||||
budgetMonth: number;
|
budgetMonth: number;
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
|
pivotConfig: PivotConfig;
|
||||||
|
pivotResult: PivotResult;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -33,19 +41,30 @@ type ReportsAction =
|
||||||
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] };
|
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
||||||
|
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||||
|
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||||
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||||
|
| { type: "SET_SOURCE_ID"; payload: number | null };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const initialState: ReportsState = {
|
const initialState: ReportsState = {
|
||||||
tab: "trends",
|
tab: "trends",
|
||||||
period: "6months",
|
period: "6months",
|
||||||
|
customDateFrom: monthStartStr,
|
||||||
|
customDateTo: todayStr,
|
||||||
|
sourceId: null,
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
budgetYear: now.getFullYear(),
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
budgetMonth: now.getMonth() + 1,
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
|
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||||
|
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -70,42 +89,19 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
case "SET_BUDGET_VS_ACTUAL":
|
case "SET_BUDGET_VS_ACTUAL":
|
||||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
||||||
|
case "SET_PIVOT_CONFIG":
|
||||||
|
return { ...state, pivotConfig: action.payload };
|
||||||
|
case "SET_PIVOT_RESULT":
|
||||||
|
return { ...state, pivotResult: action.payload, isLoading: false };
|
||||||
|
case "SET_CUSTOM_DATES":
|
||||||
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
|
case "SET_SOURCE_ID":
|
||||||
|
return { ...state, sourceId: action.payload };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
|
||||||
if (period === "all") return {};
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
|
|
||||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
let from: Date;
|
|
||||||
switch (period) {
|
|
||||||
case "month":
|
|
||||||
from = new Date(year, month, 1);
|
|
||||||
break;
|
|
||||||
case "3months":
|
|
||||||
from = new Date(year, month - 2, 1);
|
|
||||||
break;
|
|
||||||
case "6months":
|
|
||||||
from = new Date(year, month - 5, 1);
|
|
||||||
break;
|
|
||||||
case "12months":
|
|
||||||
from = new Date(year, month - 11, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReports() {
|
export function useReports() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
@ -115,6 +111,10 @@ export function useReports() {
|
||||||
period: DashboardPeriod,
|
period: DashboardPeriod,
|
||||||
budgetYear: number,
|
budgetYear: number,
|
||||||
budgetMonth: number,
|
budgetMonth: number,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
|
pivotCfg?: PivotConfig,
|
||||||
|
srcId?: number | null,
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -123,22 +123,22 @@ export function useReports() {
|
||||||
try {
|
try {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "trends": {
|
case "trends": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "byCategory": {
|
case "byCategory": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "overTime": {
|
case "overTime": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
break;
|
break;
|
||||||
|
|
@ -149,6 +149,16 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "dynamic": {
|
||||||
|
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
|
||||||
|
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = await getDynamicReportData(pivotCfg);
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
|
@ -160,8 +170,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth);
|
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
|
||||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -171,18 +181,21 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||||
let newMonth = state.budgetMonth + delta;
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||||
let newYear = state.budgetYear;
|
}, []);
|
||||||
if (newMonth < 1) {
|
|
||||||
newMonth = 12;
|
|
||||||
newYear -= 1;
|
|
||||||
} else if (newMonth > 12) {
|
|
||||||
newMonth = 1;
|
|
||||||
newYear += 1;
|
|
||||||
}
|
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
|
|
||||||
}, [state.budgetYear, state.budgetMonth]);
|
|
||||||
|
|
||||||
return { state, setTab, setPeriod, navigateBudgetMonth };
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPivotConfig = useCallback((config: PivotConfig) => {
|
||||||
|
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSourceId = useCallback((id: number | null) => {
|
||||||
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ type UpdateStatus =
|
||||||
interface UpdaterState {
|
interface UpdaterState {
|
||||||
status: UpdateStatus;
|
status: UpdateStatus;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
body: string | null;
|
||||||
progress: number;
|
progress: number;
|
||||||
contentLength: number | null;
|
contentLength: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -23,7 +24,7 @@ interface UpdaterState {
|
||||||
type UpdaterAction =
|
type UpdaterAction =
|
||||||
| { type: "CHECK_START" }
|
| { type: "CHECK_START" }
|
||||||
| { type: "UP_TO_DATE" }
|
| { type: "UP_TO_DATE" }
|
||||||
| { type: "AVAILABLE"; version: string }
|
| { type: "AVAILABLE"; version: string; body: string | null }
|
||||||
| { type: "DOWNLOAD_START" }
|
| { type: "DOWNLOAD_START" }
|
||||||
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
||||||
| { type: "READY_TO_INSTALL" }
|
| { type: "READY_TO_INSTALL" }
|
||||||
|
|
@ -33,6 +34,7 @@ type UpdaterAction =
|
||||||
const initialState: UpdaterState = {
|
const initialState: UpdaterState = {
|
||||||
status: "idle",
|
status: "idle",
|
||||||
version: null,
|
version: null,
|
||||||
|
body: null,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
contentLength: null,
|
contentLength: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -45,7 +47,7 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||||
case "UP_TO_DATE":
|
case "UP_TO_DATE":
|
||||||
return { ...state, status: "upToDate", error: null };
|
return { ...state, status: "upToDate", error: null };
|
||||||
case "AVAILABLE":
|
case "AVAILABLE":
|
||||||
return { ...state, status: "available", version: action.version, error: null };
|
return { ...state, status: "available", version: action.version, body: action.body, error: null };
|
||||||
case "DOWNLOAD_START":
|
case "DOWNLOAD_START":
|
||||||
return { ...state, status: "downloading", progress: 0, contentLength: null, error: null };
|
return { ...state, status: "downloading", progress: 0, contentLength: null, error: null };
|
||||||
case "DOWNLOAD_PROGRESS":
|
case "DOWNLOAD_PROGRESS":
|
||||||
|
|
@ -69,7 +71,7 @@ export function useUpdater() {
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
updateRef.current = update;
|
updateRef.current = update;
|
||||||
dispatch({ type: "AVAILABLE", version: update.version });
|
dispatch({ type: "AVAILABLE", version: update.version, body: update.body ?? null });
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "UP_TO_DATE" });
|
dispatch({ type: "UP_TO_DATE" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Simpl'Result"
|
"name": "Simpl'Result"
|
||||||
},
|
},
|
||||||
|
"changelog": {
|
||||||
|
"title": "Version History",
|
||||||
|
"description": "View what's new and fixed in each version",
|
||||||
|
"empty": "No entries available"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|
@ -17,17 +22,24 @@
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
|
"net": "Net",
|
||||||
"noData": "No data available. Start by importing your bank statements.",
|
"noData": "No data available. Start by importing your bank statements.",
|
||||||
"expensesByCategory": "Expenses by Category",
|
"expensesByCategory": "Expenses by Category",
|
||||||
"recentTransactions": "Recent Transactions",
|
"recentTransactions": "Recent Transactions",
|
||||||
|
"budgetVsActual": "Budget vs Actual",
|
||||||
|
"expensesOverTime": "Expenses Over Time",
|
||||||
"period": {
|
"period": {
|
||||||
"month": "This month",
|
"month": "This month",
|
||||||
"3months": "3 months",
|
"3months": "3 months",
|
||||||
"6months": "6 months",
|
"6months": "6 months",
|
||||||
"12months": "12 months",
|
"12months": "12 months",
|
||||||
"year": "This year",
|
"year": "This year",
|
||||||
"all": "All"
|
"all": "All",
|
||||||
|
"custom": "Custom"
|
||||||
},
|
},
|
||||||
|
"dateFrom": "From",
|
||||||
|
"dateTo": "To",
|
||||||
|
"apply": "Apply",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use the Dashboard",
|
"title": "How to use the Dashboard",
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -307,13 +319,18 @@
|
||||||
"actual": "Actual",
|
"actual": "Actual",
|
||||||
"difference": "Difference",
|
"difference": "Difference",
|
||||||
"annual": "Annual",
|
"annual": "Annual",
|
||||||
|
"previousYear": "Prev. Year",
|
||||||
"splitEvenly": "Split evenly across 12 months",
|
"splitEvenly": "Split evenly across 12 months",
|
||||||
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
||||||
|
"clickToEdit": "Click to edit",
|
||||||
"applyToMonth": "Apply to month",
|
"applyToMonth": "Apply to month",
|
||||||
"allMonths": "All 12 months",
|
"allMonths": "All 12 months",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"transfers": "Transfers",
|
"transfers": "Transfers",
|
||||||
|
"totalExpenses": "Total Expenses",
|
||||||
|
"totalIncome": "Total Income",
|
||||||
|
"totalTransfers": "Total Transfers",
|
||||||
"totalPlanned": "Total Planned",
|
"totalPlanned": "Total Planned",
|
||||||
"totalActual": "Total Actual",
|
"totalActual": "Total Actual",
|
||||||
"totalDifference": "Difference",
|
"totalDifference": "Difference",
|
||||||
|
|
@ -343,21 +360,62 @@
|
||||||
"overTime": "Category Over Time",
|
"overTime": "Category Over Time",
|
||||||
"trends": "Monthly Trends",
|
"trends": "Monthly Trends",
|
||||||
"budgetVsActual": "Budget vs Actual",
|
"budgetVsActual": "Budget vs Actual",
|
||||||
|
"subtotalsOnTop": "Subtotals on top",
|
||||||
|
"subtotalsOnBottom": "Subtotals on bottom",
|
||||||
|
"detail": {
|
||||||
|
"showAmounts": "Show amounts",
|
||||||
|
"hideAmounts": "Hide amounts"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Categories",
|
||||||
|
"search": "Search...",
|
||||||
|
"all": "All",
|
||||||
|
"none": "None"
|
||||||
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
"ytd": "Year-to-Date",
|
"ytd": "Year-to-Date",
|
||||||
"dollarVar": "$ Var",
|
"dollarVar": "$ Var",
|
||||||
"pctVar": "% Var",
|
"pctVar": "% Var",
|
||||||
"noData": "No budget or transaction data for this period."
|
"noData": "No budget or transaction data for this period.",
|
||||||
|
"titlePrefix": "Budget vs Actual for"
|
||||||
},
|
},
|
||||||
|
"dynamic": "Dynamic Report",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
"pivot": {
|
||||||
|
"availableFields": "Available Fields",
|
||||||
|
"rows": "Rows",
|
||||||
|
"columns": "Columns",
|
||||||
|
"filters": "Filters",
|
||||||
|
"values": "Values",
|
||||||
|
"addTo": "Add to...",
|
||||||
|
"year": "Year",
|
||||||
|
"month": "Month",
|
||||||
|
"categoryType": "Type",
|
||||||
|
"level1": "Category (Level 1)",
|
||||||
|
"level2": "Category (Level 2)",
|
||||||
|
"level3": "Category (Level 3)",
|
||||||
|
"periodic": "Periodic Amount",
|
||||||
|
"ytd": "Year-to-Date (YTD)",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"total": "Total",
|
||||||
|
"viewTable": "Table",
|
||||||
|
"viewChart": "Chart",
|
||||||
|
"viewBoth": "Both",
|
||||||
|
"noConfig": "Add fields to generate the report",
|
||||||
|
"noData": "No data for this configuration",
|
||||||
|
"fullscreen": "Full screen",
|
||||||
|
"exitFullscreen": "Exit full screen",
|
||||||
|
"rightClickExclude": "Right-click to exclude"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use Reports",
|
"title": "How to use Reports",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Switch between Trends, By Category, and Over Time views using the tabs",
|
"Switch between Trends, By Category, and Over Time views using the tabs",
|
||||||
"Use the period selector to adjust the time range for all charts",
|
"Use the period selector to adjust the time range for all charts",
|
||||||
"Monthly Trends shows your income and expenses over time",
|
"Monthly Trends shows your income and expenses over time",
|
||||||
"Category Over Time tracks how spending in each category evolves"
|
"Category Over Time tracks how spending in each category evolves",
|
||||||
|
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -376,7 +434,8 @@
|
||||||
"installButton": "Install and restart",
|
"installButton": "Install and restart",
|
||||||
"installing": "Installing...",
|
"installing": "Installing...",
|
||||||
"error": "Update failed",
|
"error": "Update failed",
|
||||||
"retryButton": "Retry"
|
"retryButton": "Retry",
|
||||||
|
"releaseNotes": "What's New"
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
|
|
@ -425,6 +484,14 @@
|
||||||
"title": "User Guide",
|
"title": "User Guide",
|
||||||
"description": "Learn how to use all features of the app"
|
"description": "Learn how to use all features of the app"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Logs",
|
||||||
|
"clear": "Clear",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"empty": "No logs",
|
||||||
|
"filterAll": "All"
|
||||||
|
},
|
||||||
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.",
|
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "About Settings",
|
"title": "About Settings",
|
||||||
|
|
@ -468,14 +535,19 @@
|
||||||
"tipsHeader": "Tips",
|
"tipsHeader": "Tips",
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Getting Started",
|
"title": "Getting Started",
|
||||||
"overview": "Simpl'Result helps you track your personal finances by importing bank statements, categorizing transactions, setting budgets, and generating reports.",
|
"overview": "Simpl'Result helps you track your personal finances by importing bank statements, categorizing transactions, setting budgets, and generating reports. The app is available on Windows and Linux.",
|
||||||
"features": [
|
"features": [
|
||||||
"Import CSV bank statements from multiple sources",
|
"Import CSV bank statements from multiple sources",
|
||||||
"Automatic and manual transaction categorization",
|
"Automatic and manual transaction categorization",
|
||||||
|
"Split a transaction across multiple categories",
|
||||||
"Budget planning with monthly and annual views",
|
"Budget planning with monthly and annual views",
|
||||||
"Visual reports and trend analysis"
|
"Visual reports and interactive charts with context menu",
|
||||||
|
"Multiple profiles with separate databases and optional PIN",
|
||||||
|
"Dark mode with warm gray palette",
|
||||||
|
"Data export and import with optional AES-256 encryption"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"On first launch, choose or create a profile — each profile has its own database",
|
||||||
"Go to Settings and set your import folder — create one subfolder per bank account",
|
"Go to Settings and set your import folder — create one subfolder per bank account",
|
||||||
"Place your CSV bank statements in the corresponding subfolder",
|
"Place your CSV bank statements in the corresponding subfolder",
|
||||||
"Open the Import page and configure your source (column mapping, delimiter, date format)",
|
"Open the Import page and configure your source (column mapping, delimiter, date format)",
|
||||||
|
|
@ -485,28 +557,57 @@
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"You can switch between English and French using the language selector in the sidebar",
|
"You can switch between English and French using the language selector in the sidebar",
|
||||||
|
"Toggle dark mode using the button in the sidebar",
|
||||||
"Each page has a help icon (?) in the header with quick tips",
|
"Each page has a help icon (?) in the header with quick tips",
|
||||||
"Your data is stored locally on your computer — nothing is sent to the cloud"
|
"Your data is stored locally on your computer — nothing is sent to the cloud"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Profiles",
|
||||||
|
"overview": "Manage multiple independent profiles, each with its own database. Ideal for separating personal and business finances, or for multiple users on the same computer.",
|
||||||
|
"features": [
|
||||||
|
"Create multiple profiles with custom names and colors",
|
||||||
|
"Each profile has its own separate database",
|
||||||
|
"Optional PIN protection (numeric code)",
|
||||||
|
"Quick profile switching from the sidebar",
|
||||||
|
"Delete a profile along with all its data"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Click the profile selector in the sidebar to see available profiles",
|
||||||
|
"Click Manage Profiles to create, edit, or delete profiles",
|
||||||
|
"Create a new profile by choosing a name, color, and optional PIN",
|
||||||
|
"Switch between profiles by clicking the one you want in the selector"
|
||||||
|
],
|
||||||
|
"tips": [
|
||||||
|
"A default profile is automatically created on first launch",
|
||||||
|
"The PIN is requested each time you access a protected profile",
|
||||||
|
"Deleting a profile permanently removes all its data — this action cannot be undone"
|
||||||
|
]
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"overview": "The Dashboard gives you an at-a-glance summary of your financial situation for a selected time period.",
|
"overview": "The Dashboard gives you an at-a-glance summary of your financial situation for a selected time period.",
|
||||||
"features": [
|
"features": [
|
||||||
"Balance, income, and expense summary cards",
|
"Balance, income, and expense summary cards",
|
||||||
"Expense breakdown by category (pie chart)",
|
"Expense breakdown by category (pie chart with SVG patterns)",
|
||||||
"Recent transactions list",
|
"Budget vs Actual table for the current month (variance in $ and %)",
|
||||||
"Adjustable time period selector"
|
"Stacked bar chart of expenses by category and month",
|
||||||
|
"Adjustable time period selector (default: year to date)",
|
||||||
|
"Context menu (right-click) to hide a category or view its transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
||||||
"Review the summary cards for your balance, total income, and total expenses",
|
"Review the summary cards for your balance, total income, and total expenses",
|
||||||
"Check the pie chart to see how your spending is distributed across categories",
|
"Check the pie chart to see how your spending is distributed across categories",
|
||||||
"Scroll down to see your most recent transactions"
|
"Review the Budget vs Actual table to compare your spending against your plan for the current month",
|
||||||
|
"Analyze the stacked bar chart at the bottom to see expense trends by category over time",
|
||||||
|
"Right-click a category in a chart to hide it or view its transaction details"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
|
"The default period is year to date for an annual overview when you open the app",
|
||||||
"The balance is calculated as income minus expenses for the selected period",
|
"The balance is calculated as income minus expenses for the selected period",
|
||||||
"Click on a category in the pie chart to view its transactions"
|
"Hidden categories appear as dismissible chips above the chart — click Show All to restore them",
|
||||||
|
"SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -543,6 +644,7 @@
|
||||||
"Inline category assignment via dropdown",
|
"Inline category assignment via dropdown",
|
||||||
"Auto-categorize based on keyword rules",
|
"Auto-categorize based on keyword rules",
|
||||||
"Add keywords directly from a transaction",
|
"Add keywords directly from a transaction",
|
||||||
|
"Split a transaction across multiple categories",
|
||||||
"Transaction notes"
|
"Transaction notes"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -550,12 +652,14 @@
|
||||||
"Click a column header to sort ascending or descending",
|
"Click a column header to sort ascending or descending",
|
||||||
"To categorize a transaction, click its category dropdown and select a category",
|
"To categorize a transaction, click its category dropdown and select a category",
|
||||||
"To auto-categorize all uncategorized transactions, click the Auto-categorize button",
|
"To auto-categorize all uncategorized transactions, click the Auto-categorize button",
|
||||||
"To add a keyword rule from a transaction, click the + icon and enter the keyword"
|
"To add a keyword rule from a transaction, click the + icon and enter the keyword",
|
||||||
|
"To split a transaction across categories, use the Split button and add amounts per category"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Use the quick period buttons (This month, Last month, etc.) for fast date filtering",
|
"Use the quick period buttons (This month, Last month, etc.) for fast date filtering",
|
||||||
"Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments",
|
"Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments",
|
||||||
"Adding a keyword from a transaction pre-fills the category so you can quickly build rules"
|
"Adding a keyword from a transaction pre-fills the category so you can quickly build rules",
|
||||||
|
"Split transactions display a visual indicator and show the breakdown details"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -566,12 +670,15 @@
|
||||||
"Three category types: Expense, Income, Transfer",
|
"Three category types: Expense, Income, Transfer",
|
||||||
"Keyword rules with priority levels for auto-categorization",
|
"Keyword rules with priority levels for auto-categorization",
|
||||||
"Custom colors for chart display",
|
"Custom colors for chart display",
|
||||||
|
"Drag-and-drop to reorder categories or change their parent",
|
||||||
"Toggle categories as inputable or non-inputable",
|
"Toggle categories as inputable or non-inputable",
|
||||||
|
"All Keywords view to see all rules at a glance",
|
||||||
"Re-initialize categories to defaults"
|
"Re-initialize categories to defaults"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Click Add Category to create a new category — choose a name, type, and optional parent",
|
"Click Add Category to create a new category — choose a name, type, and optional parent",
|
||||||
"Select a category in the tree to view its details and keyword list",
|
"Select a category in the tree to view its details and keyword list",
|
||||||
|
"Drag and drop a category in the tree to reorder it or move it under a different parent",
|
||||||
"Add keywords that match transaction descriptions for auto-categorization",
|
"Add keywords that match transaction descriptions for auto-categorization",
|
||||||
"Set keyword priority to resolve conflicts when multiple categories match",
|
"Set keyword priority to resolve conflicts when multiple categories match",
|
||||||
"Use the color picker to assign a custom color for charts"
|
"Use the color picker to assign a custom color for charts"
|
||||||
|
|
@ -579,25 +686,29 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports",
|
"Non-inputable categories are hidden from budget and transaction dropdowns but still visible in reports",
|
||||||
"Higher priority keywords win when multiple categories match the same transaction",
|
"Higher priority keywords win when multiple categories match the same transaction",
|
||||||
|
"Use the All Keywords view to get a global overview of your categorization rules",
|
||||||
"Use Re-initialize to reset categories to defaults — this will unlink all transaction categories"
|
"Use Re-initialize to reset categories to defaults — this will unlink all transaction categories"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"adjustments": {
|
"adjustments": {
|
||||||
"title": "Adjustments",
|
"title": "Adjustments",
|
||||||
"overview": "Add manual entries that don't come from bank imports — useful for expected expenses, income, or corrections not yet in your statements.",
|
"overview": "Add manual entries that don't come from bank imports, and view transaction splits created from the Transactions page.",
|
||||||
"features": [
|
"features": [
|
||||||
"Create named adjustment groups with multiple entries",
|
"Create named adjustment groups with multiple entries",
|
||||||
"Assign a category to each entry",
|
"Assign a category to each entry",
|
||||||
"Mark adjustments as recurring"
|
"Mark adjustments as recurring",
|
||||||
|
"View transaction splits in a dedicated section"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Click New Adjustment to create an adjustment group",
|
"Click New Adjustment to create an adjustment group",
|
||||||
"Add entries with a description, amount, date, and category",
|
"Add entries with a description, amount, date, and category",
|
||||||
"Toggle the recurring flag if the adjustment should repeat each period"
|
"Toggle the recurring flag if the adjustment should repeat each period",
|
||||||
|
"View the Transaction Splits section to see splits created from the Transactions page"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Adjustments appear in your budget actuals alongside imported transactions",
|
"Adjustments appear in your budget actuals alongside imported transactions",
|
||||||
"Use adjustments for planned expenses that haven't hit your bank account yet"
|
"Use adjustments for planned expenses that haven't hit your bank account yet",
|
||||||
|
"Transaction splits are created from the Transactions page and appear here automatically"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"budget": {
|
"budget": {
|
||||||
|
|
@ -608,7 +719,8 @@
|
||||||
"Annual column with automatic totals",
|
"Annual column with automatic totals",
|
||||||
"Split annual amount evenly across 12 months",
|
"Split annual amount evenly across 12 months",
|
||||||
"Budget templates to save and apply configurations",
|
"Budget templates to save and apply configurations",
|
||||||
"Parent category subtotals"
|
"Parent category subtotals",
|
||||||
|
"Column headers stay fixed when scrolling vertically"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Use the year navigator to select the budget year",
|
"Use the year navigator to select the budget year",
|
||||||
|
|
@ -630,32 +742,45 @@
|
||||||
"Monthly Trends: income vs. expenses over time (bar chart)",
|
"Monthly Trends: income vs. expenses over time (bar chart)",
|
||||||
"Expenses by Category: spending breakdown (pie chart)",
|
"Expenses by Category: spending breakdown (pie chart)",
|
||||||
"Category Over Time: track how each category evolves (line chart)",
|
"Category Over Time: track how each category evolves (line chart)",
|
||||||
"Budget vs Actual: monthly and year-to-date comparison table"
|
"Budget vs Actual: monthly and year-to-date comparison table",
|
||||||
|
"Dynamic Report: customizable pivot table",
|
||||||
|
"SVG patterns (lines, dots, crosshatch) to distinguish categories",
|
||||||
|
"Context menu (right-click) to hide a category or view its transactions",
|
||||||
|
"Transaction detail by category with sortable columns (date, description, amount)",
|
||||||
|
"Toggle to show or hide amounts in transaction detail"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
|
"Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
|
||||||
"Adjust the time period using the period selector",
|
"Adjust the time period using the period selector",
|
||||||
"In the pie chart, click a category to hide/show it",
|
"Right-click a category in any chart to hide it or view its transaction details",
|
||||||
"In Budget vs Actual, toggle between Monthly and Year-to-Date views"
|
"Hidden categories appear as dismissible chips above the chart — click them to show again",
|
||||||
|
"In Budget vs Actual, toggle between Monthly and Year-to-Date views",
|
||||||
|
"In the category detail, click a column header to sort transactions",
|
||||||
|
"Use the eye icon in the detail view to show or hide the amounts column"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Hidden categories are remembered while you stay on the page — click Show All to reset",
|
"Hidden categories are remembered while you stay on the page — click Show All to reset",
|
||||||
"The period selector applies to all chart tabs simultaneously",
|
"The period selector applies to all chart tabs simultaneously",
|
||||||
"Budget vs Actual shows dollar and percentage variance for each category"
|
"Budget vs Actual shows dollar and percentage variance for each category",
|
||||||
|
"SVG patterns help colorblind users distinguish categories in charts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"overview": "Configure app preferences, check for updates, and manage your data with export/import tools.",
|
"overview": "Configure app preferences, check for updates, access the user guide, and manage your data with export/import tools.",
|
||||||
"features": [
|
"features": [
|
||||||
"App version display",
|
"App version display",
|
||||||
|
"Complete user guide accessible directly from settings",
|
||||||
"Automatic update checker with one-click install",
|
"Automatic update checker with one-click install",
|
||||||
|
"Application logs viewable with level filters, copy, and clear",
|
||||||
"Data export (transactions, categories, or both) in JSON or CSV format",
|
"Data export (transactions, categories, or both) in JSON or CSV format",
|
||||||
"Data import from a previously exported file",
|
"Data import from a previously exported file",
|
||||||
"Optional encryption for exported files"
|
"Optional AES-256-GCM encryption for exported files"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"Click User Guide to access the full documentation",
|
||||||
"Click Check for Updates to see if a new version is available",
|
"Click Check for Updates to see if a new version is available",
|
||||||
|
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
|
||||||
"Use the Data Management section to export or import your data",
|
"Use the Data Management section to export or import your data",
|
||||||
"When exporting, choose what to include and optionally set a password for encryption",
|
"When exporting, choose what to include and optionally set a password for encryption",
|
||||||
"When importing, select a previously exported file — encrypted files will prompt for the password"
|
"When importing, select a previously exported file — encrypted files will prompt for the password"
|
||||||
|
|
@ -663,7 +788,10 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Updates only replace the app binary — your database is never modified",
|
"Updates only replace the app binary — your database is never modified",
|
||||||
"Change the app language using the language selector in the sidebar",
|
"Change the app language using the language selector in the sidebar",
|
||||||
"Export regularly to keep a backup of your data"
|
"Export regularly to keep a backup of your data",
|
||||||
|
"The user guide can be printed or exported to PDF via the Print button",
|
||||||
|
"Logs persist for the session — they survive a page refresh",
|
||||||
|
"If you encounter an issue, copy the logs and attach them to your report"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -688,6 +816,20 @@
|
||||||
"manageProfiles": "Manage Profiles",
|
"manageProfiles": "Manage Profiles",
|
||||||
"default": "Default"
|
"default": "Default"
|
||||||
},
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "An error occurred",
|
||||||
|
"startupTimeout": "Database connection timed out",
|
||||||
|
"unexpectedError": "An unexpected error occurred",
|
||||||
|
"showDetails": "Show details",
|
||||||
|
"hideDetails": "Hide details",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"checkUpdate": "Check for updates",
|
||||||
|
"updateAvailable": "Update available: v{{version}}",
|
||||||
|
"upToDate": "The application is up to date",
|
||||||
|
"contactUs": "Contact us",
|
||||||
|
"contactEmail": "Send an email to",
|
||||||
|
"reportIssue": "Report an issue"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Simpl'Résultat"
|
"name": "Simpl'Résultat"
|
||||||
},
|
},
|
||||||
|
"changelog": {
|
||||||
|
"title": "Historique des versions",
|
||||||
|
"description": "Consultez les nouveautés et corrections de chaque version",
|
||||||
|
"empty": "Aucune entrée disponible"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
|
|
@ -17,17 +22,24 @@
|
||||||
"balance": "Solde",
|
"balance": "Solde",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
|
"net": "Net",
|
||||||
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
||||||
"expensesByCategory": "Dépenses par catégorie",
|
"expensesByCategory": "Dépenses par catégorie",
|
||||||
"recentTransactions": "Transactions récentes",
|
"recentTransactions": "Transactions récentes",
|
||||||
|
"budgetVsActual": "Budget vs Réel",
|
||||||
|
"expensesOverTime": "Dépenses dans le temps",
|
||||||
"period": {
|
"period": {
|
||||||
"month": "Ce mois",
|
"month": "Ce mois",
|
||||||
"3months": "3 mois",
|
"3months": "3 mois",
|
||||||
"6months": "6 mois",
|
"6months": "6 mois",
|
||||||
"12months": "12 mois",
|
"12months": "12 mois",
|
||||||
"year": "Cette année",
|
"year": "Cette année",
|
||||||
"all": "Tout"
|
"all": "Tout",
|
||||||
|
"custom": "Personnalisé"
|
||||||
},
|
},
|
||||||
|
"dateFrom": "Du",
|
||||||
|
"dateTo": "Au",
|
||||||
|
"apply": "Appliquer",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser le tableau de bord",
|
"title": "Comment utiliser le tableau de bord",
|
||||||
"tips": [
|
"tips": [
|
||||||
|
|
@ -307,13 +319,18 @@
|
||||||
"actual": "Réel",
|
"actual": "Réel",
|
||||||
"difference": "Écart",
|
"difference": "Écart",
|
||||||
"annual": "Annuel",
|
"annual": "Annuel",
|
||||||
|
"previousYear": "Année préc.",
|
||||||
"splitEvenly": "Répartir également sur 12 mois",
|
"splitEvenly": "Répartir également sur 12 mois",
|
||||||
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
||||||
|
"clickToEdit": "Cliquer pour modifier",
|
||||||
"applyToMonth": "Appliquer au mois",
|
"applyToMonth": "Appliquer au mois",
|
||||||
"allMonths": "Les 12 mois",
|
"allMonths": "Les 12 mois",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"transfers": "Transferts",
|
"transfers": "Transferts",
|
||||||
|
"totalExpenses": "Total des dépenses",
|
||||||
|
"totalIncome": "Total des revenus",
|
||||||
|
"totalTransfers": "Total des transferts",
|
||||||
"totalPlanned": "Total prévu",
|
"totalPlanned": "Total prévu",
|
||||||
"totalActual": "Total réel",
|
"totalActual": "Total réel",
|
||||||
"totalDifference": "Écart",
|
"totalDifference": "Écart",
|
||||||
|
|
@ -343,21 +360,62 @@
|
||||||
"overTime": "Catégories dans le temps",
|
"overTime": "Catégories dans le temps",
|
||||||
"trends": "Tendances mensuelles",
|
"trends": "Tendances mensuelles",
|
||||||
"budgetVsActual": "Budget vs R\u00e9el",
|
"budgetVsActual": "Budget vs R\u00e9el",
|
||||||
|
"subtotalsOnTop": "Sous-totaux en haut",
|
||||||
|
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||||
|
"detail": {
|
||||||
|
"showAmounts": "Afficher les montants",
|
||||||
|
"hideAmounts": "Masquer les montants"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Catégories",
|
||||||
|
"search": "Rechercher...",
|
||||||
|
"all": "Toutes",
|
||||||
|
"none": "Aucune"
|
||||||
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
"ytd": "Cumul annuel",
|
"ytd": "Cumul annuel",
|
||||||
"dollarVar": "$ \u00c9cart",
|
"dollarVar": "$ \u00c9cart",
|
||||||
"pctVar": "% \u00c9cart",
|
"pctVar": "% \u00c9cart",
|
||||||
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
|
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
|
||||||
|
"titlePrefix": "Budget vs Réel pour le mois de"
|
||||||
},
|
},
|
||||||
|
"dynamic": "Rapport dynamique",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
|
"pivot": {
|
||||||
|
"availableFields": "Champs disponibles",
|
||||||
|
"rows": "Lignes",
|
||||||
|
"columns": "Colonnes",
|
||||||
|
"filters": "Filtres",
|
||||||
|
"values": "Valeurs",
|
||||||
|
"addTo": "Ajouter à...",
|
||||||
|
"year": "Année",
|
||||||
|
"month": "Mois",
|
||||||
|
"categoryType": "Type",
|
||||||
|
"level1": "Catégorie (Niveau 1)",
|
||||||
|
"level2": "Catégorie (Niveau 2)",
|
||||||
|
"level3": "Catégorie (Niveau 3)",
|
||||||
|
"periodic": "Montant périodique",
|
||||||
|
"ytd": "Cumul annuel (YTD)",
|
||||||
|
"subtotal": "Sous-total",
|
||||||
|
"total": "Total",
|
||||||
|
"viewTable": "Tableau",
|
||||||
|
"viewChart": "Graphique",
|
||||||
|
"viewBoth": "Les deux",
|
||||||
|
"noConfig": "Ajoutez des champs pour générer le rapport",
|
||||||
|
"noData": "Aucune donnée pour cette configuration",
|
||||||
|
"fullscreen": "Plein écran",
|
||||||
|
"exitFullscreen": "Quitter plein écran",
|
||||||
|
"rightClickExclude": "Clic-droit pour exclure"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser les Rapports",
|
"title": "Comment utiliser les Rapports",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
|
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
|
||||||
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
|
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
|
||||||
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
|
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
|
||||||
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
|
"Catégories dans le temps suit l'évolution des dépenses par catégorie",
|
||||||
|
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -376,7 +434,8 @@
|
||||||
"installButton": "Installer et redémarrer",
|
"installButton": "Installer et redémarrer",
|
||||||
"installing": "Installation en cours...",
|
"installing": "Installation en cours...",
|
||||||
"error": "Erreur lors de la mise à jour",
|
"error": "Erreur lors de la mise à jour",
|
||||||
"retryButton": "Réessayer"
|
"retryButton": "Réessayer",
|
||||||
|
"releaseNotes": "Nouveautés"
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Gestion des données",
|
"title": "Gestion des données",
|
||||||
|
|
@ -425,6 +484,14 @@
|
||||||
"title": "Guide d'utilisation",
|
"title": "Guide d'utilisation",
|
||||||
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Journaux",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié !",
|
||||||
|
"empty": "Aucun journal",
|
||||||
|
"filterAll": "Tout"
|
||||||
|
},
|
||||||
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.",
|
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "À propos des Paramètres",
|
"title": "À propos des Paramètres",
|
||||||
|
|
@ -468,14 +535,19 @@
|
||||||
"tipsHeader": "Astuces",
|
"tipsHeader": "Astuces",
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Premiers pas",
|
"title": "Premiers pas",
|
||||||
"overview": "Simpl'Résultat vous aide à suivre vos finances personnelles en important des relevés bancaires, en catégorisant les transactions, en planifiant des budgets et en générant des rapports.",
|
"overview": "Simpl'Résultat vous aide à suivre vos finances personnelles en important des relevés bancaires, en catégorisant les transactions, en planifiant des budgets et en générant des rapports. L'application est disponible sur Windows et Linux.",
|
||||||
"features": [
|
"features": [
|
||||||
"Importation de relevés bancaires CSV depuis plusieurs sources",
|
"Importation de relevés bancaires CSV depuis plusieurs sources",
|
||||||
"Catégorisation automatique et manuelle des transactions",
|
"Catégorisation automatique et manuelle des transactions",
|
||||||
|
"Répartition (split) d'une transaction sur plusieurs catégories",
|
||||||
"Planification budgétaire avec vues mensuelles et annuelles",
|
"Planification budgétaire avec vues mensuelles et annuelles",
|
||||||
"Rapports visuels et analyse des tendances"
|
"Rapports visuels et graphiques interactifs avec menu contextuel",
|
||||||
|
"Profils multiples avec bases de données séparées et NIP optionnel",
|
||||||
|
"Mode sombre avec palette gris chaud",
|
||||||
|
"Export et import de données avec chiffrement AES-256 optionnel"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"Au premier lancement, choisissez ou créez un profil — chaque profil a sa propre base de données",
|
||||||
"Allez dans Paramètres et définissez votre dossier d'import — créez un sous-dossier par compte bancaire",
|
"Allez dans Paramètres et définissez votre dossier d'import — créez un sous-dossier par compte bancaire",
|
||||||
"Placez vos relevés bancaires CSV dans le sous-dossier correspondant",
|
"Placez vos relevés bancaires CSV dans le sous-dossier correspondant",
|
||||||
"Ouvrez la page Import et configurez votre source (mapping des colonnes, délimiteur, format de date)",
|
"Ouvrez la page Import et configurez votre source (mapping des colonnes, délimiteur, format de date)",
|
||||||
|
|
@ -485,28 +557,57 @@
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale",
|
"Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale",
|
||||||
|
"Activez le mode sombre via le bouton dans la barre latérale",
|
||||||
"Chaque page a une icône d'aide (?) dans l'en-tête avec des astuces rapides",
|
"Chaque page a une icône d'aide (?) dans l'en-tête avec des astuces rapides",
|
||||||
"Vos données sont stockées localement sur votre ordinateur — rien n'est envoyé vers le cloud"
|
"Vos données sont stockées localement sur votre ordinateur — rien n'est envoyé vers le cloud"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Profils",
|
||||||
|
"overview": "Gérez plusieurs profils indépendants, chacun avec sa propre base de données. Idéal pour séparer les finances personnelles et professionnelles, ou pour plusieurs utilisateurs sur un même ordinateur.",
|
||||||
|
"features": [
|
||||||
|
"Création de profils multiples avec noms et couleurs personnalisés",
|
||||||
|
"Chaque profil possède sa propre base de données séparée",
|
||||||
|
"Protection optionnelle par NIP (code numérique)",
|
||||||
|
"Changement de profil rapide depuis la barre latérale",
|
||||||
|
"Suppression de profil avec toutes ses données"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Cliquez sur le sélecteur de profil dans la barre latérale pour voir les profils disponibles",
|
||||||
|
"Cliquez sur Gérer les profils pour créer, modifier ou supprimer des profils",
|
||||||
|
"Créez un nouveau profil en choisissant un nom, une couleur et un NIP optionnel",
|
||||||
|
"Basculez entre les profils en cliquant sur celui de votre choix dans le sélecteur"
|
||||||
|
],
|
||||||
|
"tips": [
|
||||||
|
"Un profil par défaut est créé automatiquement au premier lancement",
|
||||||
|
"Le NIP est demandé à chaque fois que vous accédez à un profil protégé",
|
||||||
|
"La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible"
|
||||||
|
]
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"overview": "Le tableau de bord vous donne un aperçu rapide de votre situation financière pour une période sélectionnée.",
|
"overview": "Le tableau de bord vous donne un aperçu rapide de votre situation financière pour une période sélectionnée.",
|
||||||
"features": [
|
"features": [
|
||||||
"Cartes résumées du solde, des revenus et des dépenses",
|
"Cartes résumées du solde, des revenus et des dépenses",
|
||||||
"Répartition des dépenses par catégorie (graphique circulaire)",
|
"Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)",
|
||||||
"Liste des transactions récentes",
|
"Tableau Budget vs Réel du mois courant (écart en $ et %)",
|
||||||
"Sélecteur de période ajustable"
|
"Histogramme empilé des dépenses par catégorie et par mois",
|
||||||
|
"Sélecteur de période ajustable (par défaut : année à ce jour)",
|
||||||
|
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
||||||
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
||||||
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
||||||
"Faites défiler vers le bas pour voir vos transactions les plus récentes"
|
"Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant",
|
||||||
|
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps",
|
||||||
|
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
|
"La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture",
|
||||||
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
|
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
|
||||||
"Cliquez sur une catégorie dans le graphique circulaire pour voir ses transactions"
|
"Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer",
|
||||||
|
"Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -543,6 +644,7 @@
|
||||||
"Assignation de catégorie en ligne via menu déroulant",
|
"Assignation de catégorie en ligne via menu déroulant",
|
||||||
"Auto-catégorisation basée sur les règles de mots-clés",
|
"Auto-catégorisation basée sur les règles de mots-clés",
|
||||||
"Ajout de mots-clés directement depuis une transaction",
|
"Ajout de mots-clés directement depuis une transaction",
|
||||||
|
"Répartition (split) d'une transaction sur plusieurs catégories",
|
||||||
"Notes sur les transactions"
|
"Notes sur les transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -550,12 +652,14 @@
|
||||||
"Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant",
|
"Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant",
|
||||||
"Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie",
|
"Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie",
|
||||||
"Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser",
|
"Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser",
|
||||||
"Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé"
|
"Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé",
|
||||||
|
"Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date",
|
"Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date",
|
||||||
"L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles",
|
"L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles",
|
||||||
"Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles"
|
"Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles",
|
||||||
|
"Les transactions réparties affichent un indicateur visuel et le détail de la répartition"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -566,12 +670,15 @@
|
||||||
"Trois types de catégories : Dépense, Revenu, Transfert",
|
"Trois types de catégories : Dépense, Revenu, Transfert",
|
||||||
"Règles de mots-clés avec niveaux de priorité pour l'auto-catégorisation",
|
"Règles de mots-clés avec niveaux de priorité pour l'auto-catégorisation",
|
||||||
"Couleurs personnalisées pour l'affichage des graphiques",
|
"Couleurs personnalisées pour l'affichage des graphiques",
|
||||||
|
"Glisser-déposer pour réordonner les catégories ou changer leur parent",
|
||||||
"Basculer les catégories en saisissable ou non-saisissable",
|
"Basculer les catégories en saisissable ou non-saisissable",
|
||||||
|
"Vue « Tous les mots-clés » pour voir l'ensemble des règles",
|
||||||
"Réinitialiser les catégories aux valeurs par défaut"
|
"Réinitialiser les catégories aux valeurs par défaut"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel",
|
"Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel",
|
||||||
"Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés",
|
"Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés",
|
||||||
|
"Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent",
|
||||||
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",
|
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",
|
||||||
"Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent",
|
"Définissez la priorité des mots-clés pour résoudre les conflits quand plusieurs catégories correspondent",
|
||||||
"Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques"
|
"Utilisez le sélecteur de couleur pour assigner une couleur personnalisée pour les graphiques"
|
||||||
|
|
@ -579,25 +686,29 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports",
|
"Les catégories non-saisissables sont masquées du budget et des menus déroulants mais restent visibles dans les rapports",
|
||||||
"Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction",
|
"Les mots-clés de priorité supérieure l'emportent quand plusieurs catégories correspondent à la même transaction",
|
||||||
|
"Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation",
|
||||||
"Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions"
|
"Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"adjustments": {
|
"adjustments": {
|
||||||
"title": "Ajustements",
|
"title": "Ajustements",
|
||||||
"overview": "Ajoutez des entrées manuelles non issues de vos relevés bancaires — utile pour les dépenses prévues, revenus ou corrections pas encore dans vos relevés.",
|
"overview": "Ajoutez des entrées manuelles non issues de vos relevés bancaires, et consultez les répartitions de transactions créées depuis la page Transactions.",
|
||||||
"features": [
|
"features": [
|
||||||
"Créer des groupes d'ajustement nommés avec plusieurs entrées",
|
"Créer des groupes d'ajustement nommés avec plusieurs entrées",
|
||||||
"Assigner une catégorie à chaque entrée",
|
"Assigner une catégorie à chaque entrée",
|
||||||
"Marquer des ajustements comme récurrents"
|
"Marquer des ajustements comme récurrents",
|
||||||
|
"Consultation des répartitions (splits) de transactions dans une section dédiée"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement",
|
"Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement",
|
||||||
"Ajoutez des entrées avec une description, un montant, une date et une catégorie",
|
"Ajoutez des entrées avec une description, un montant, une date et une catégorie",
|
||||||
"Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période"
|
"Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période",
|
||||||
|
"Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les ajustements apparaissent dans vos réels de budget aux côtés des transactions importées",
|
"Les ajustements apparaissent dans vos réels de budget aux côtés des transactions importées",
|
||||||
"Utilisez les ajustements pour les dépenses prévues qui n'ont pas encore été débitées de votre compte"
|
"Utilisez les ajustements pour les dépenses prévues qui n'ont pas encore été débitées de votre compte",
|
||||||
|
"Les répartitions de transactions sont créées depuis la page Transactions et apparaissent automatiquement ici"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"budget": {
|
"budget": {
|
||||||
|
|
@ -608,7 +719,8 @@
|
||||||
"Colonne annuelle avec totaux automatiques",
|
"Colonne annuelle avec totaux automatiques",
|
||||||
"Répartition égale du montant annuel sur 12 mois",
|
"Répartition égale du montant annuel sur 12 mois",
|
||||||
"Modèles de budget pour sauvegarder et appliquer des configurations",
|
"Modèles de budget pour sauvegarder et appliquer des configurations",
|
||||||
"Sous-totaux par catégorie parente"
|
"Sous-totaux par catégorie parente",
|
||||||
|
"En-têtes de colonnes fixes au défilement vertical"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Utilisez le navigateur d'année pour sélectionner l'année du budget",
|
"Utilisez le navigateur d'année pour sélectionner l'année du budget",
|
||||||
|
|
@ -630,32 +742,45 @@
|
||||||
"Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)",
|
"Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)",
|
||||||
"Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
|
"Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
|
||||||
"Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
|
"Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
|
||||||
"Budget vs Réel : tableau comparatif mensuel et cumul annuel"
|
"Budget vs Réel : tableau comparatif mensuel et cumul annuel",
|
||||||
|
"Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable",
|
||||||
|
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
|
||||||
|
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
|
||||||
|
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
|
||||||
|
"Toggle pour afficher ou masquer les montants dans le détail des transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel",
|
"Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel",
|
||||||
"Ajustez la période avec le sélecteur de période",
|
"Ajustez la période avec le sélecteur de période",
|
||||||
"Dans le graphique circulaire, cliquez sur une catégorie pour la masquer/afficher",
|
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions",
|
||||||
"Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel"
|
"Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher",
|
||||||
|
"Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel",
|
||||||
|
"Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions",
|
||||||
|
"Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
|
"Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
|
||||||
"Le sélecteur de période s'applique à tous les onglets de graphiques simultanément",
|
"Le sélecteur de période s'applique à tous les onglets de graphiques simultanément",
|
||||||
"Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie"
|
"Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie",
|
||||||
|
"Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Paramètres",
|
"title": "Paramètres",
|
||||||
"overview": "Configurez les préférences de l'application, vérifiez les mises à jour et gérez vos données avec les outils d'export/import.",
|
"overview": "Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.",
|
||||||
"features": [
|
"features": [
|
||||||
"Affichage de la version de l'application",
|
"Affichage de la version de l'application",
|
||||||
|
"Guide d'utilisation complet accessible directement depuis les paramètres",
|
||||||
"Vérification automatique des mises à jour avec installation en un clic",
|
"Vérification automatique des mises à jour avec installation en un clic",
|
||||||
|
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
|
||||||
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
|
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
|
||||||
"Import des données depuis un fichier exporté précédemment",
|
"Import des données depuis un fichier exporté précédemment",
|
||||||
"Chiffrement optionnel pour les fichiers exportés"
|
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
|
||||||
"Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible",
|
"Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible",
|
||||||
|
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
|
||||||
"Utilisez la section Gestion des données pour exporter ou importer vos données",
|
"Utilisez la section Gestion des données pour exporter ou importer vos données",
|
||||||
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
|
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
|
||||||
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
|
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
|
||||||
|
|
@ -663,7 +788,10 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
||||||
"Changez la langue de l'application via le sélecteur de langue dans la barre latérale",
|
"Changez la langue de l'application via le sélecteur de langue dans la barre latérale",
|
||||||
"Exportez régulièrement pour garder une sauvegarde de vos données"
|
"Exportez régulièrement pour garder une sauvegarde de vos données",
|
||||||
|
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
|
||||||
|
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
|
||||||
|
"En cas de problème, copiez les journaux et joignez-les à votre signalement"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -688,6 +816,20 @@
|
||||||
"manageProfiles": "Gérer les profils",
|
"manageProfiles": "Gérer les profils",
|
||||||
"default": "Par défaut"
|
"default": "Par défaut"
|
||||||
},
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"startupTimeout": "La connexion à la base de données a expiré",
|
||||||
|
"unexpectedError": "Une erreur inattendue s'est produite",
|
||||||
|
"showDetails": "Afficher les détails",
|
||||||
|
"hideDetails": "Masquer les détails",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"checkUpdate": "Vérifier les mises à jour",
|
||||||
|
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||||
|
"upToDate": "L'application est à jour",
|
||||||
|
"contactUs": "Nous contacter",
|
||||||
|
"contactEmail": "Envoyez un email à",
|
||||||
|
"reportIssue": "Signaler un problème"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
|
import { initLogCapture } from "./services/logService";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initLogCapture();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</ProfileProvider>
|
</ProfileProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -112,16 +112,21 @@ export default function AdjustmentsPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
|
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||||
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
||||||
<AdjustmentListPanel
|
{state.adjustments.length > 0 && (
|
||||||
adjustments={state.adjustments}
|
<AdjustmentListPanel
|
||||||
selectedId={state.selectedAdjustmentId}
|
adjustments={state.adjustments}
|
||||||
onSelect={selectAdjustment}
|
selectedId={state.selectedAdjustmentId}
|
||||||
entriesByAdjustment={entriesMap}
|
onSelect={selectAdjustment}
|
||||||
/>
|
entriesByAdjustment={entriesMap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{splitTransactions.length > 0 && (
|
{splitTransactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mt-4 mb-2 px-1">
|
{state.adjustments.length > 0 && (
|
||||||
|
<div className="border-t border-[var(--border)] my-3" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mb-2 px-1">
|
||||||
<Split size={14} className="text-[var(--foreground)]" />
|
<Split size={14} className="text-[var(--foreground)]" />
|
||||||
<span className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide">
|
<span className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||||
{t("adjustments.splitTransactions")}
|
{t("adjustments.splitTransactions")}
|
||||||
|
|
@ -151,6 +156,12 @@ export default function AdjustmentsPage() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state.adjustments.length === 0 && splitTransactions.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-full text-[var(--muted-foreground)] text-sm">
|
||||||
|
{t("common.noResults")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AdjustmentDetailPanel
|
<AdjustmentDetailPanel
|
||||||
selectedAdjustment={selectedAdjustment}
|
selectedAdjustment={selectedAdjustment}
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,10 @@ export default function CategoriesPage() {
|
||||||
setShowAllKeywords(false);
|
setShowAllKeywords(false);
|
||||||
selectCategory(id);
|
selectCategory(id);
|
||||||
}}
|
}}
|
||||||
|
onRemove={removeKeyword}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
|
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}>
|
||||||
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
||||||
<CategoryTree
|
<CategoryTree
|
||||||
tree={state.tree}
|
tree={state.tree}
|
||||||
|
|
|
||||||
107
src/pages/ChangelogPage.tsx
Normal file
107
src/pages/ChangelogPage.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
sections: { heading: string; items: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||||
|
const entries: ChangelogEntry[] = [];
|
||||||
|
let current: ChangelogEntry | null = null;
|
||||||
|
let currentSection: { heading: string; items: string[] } | null = null;
|
||||||
|
|
||||||
|
for (const line of markdown.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Version heading: ## [0.6.0] or ## 0.6.0
|
||||||
|
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
||||||
|
if (versionMatch) {
|
||||||
|
if (currentSection && current) current.sections.push(currentSection);
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = { version: versionMatch[1], sections: [] };
|
||||||
|
currentSection = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section heading: ### Added, ### Corrigé, etc.
|
||||||
|
const sectionMatch = trimmed.match(/^### (.+)/);
|
||||||
|
if (sectionMatch && current) {
|
||||||
|
if (currentSection) current.sections.push(currentSection);
|
||||||
|
currentSection = { heading: sectionMatch[1], items: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List item
|
||||||
|
if (trimmed.startsWith("- ") && currentSection) {
|
||||||
|
currentSection.items.push(trimmed.slice(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection && current) current.sections.push(currentSection);
|
||||||
|
if (current) entries.push(current);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangelogPage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
||||||
|
fetch(file)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => setEntries(parseChangelog(text)))
|
||||||
|
.catch(() => setEntries([]));
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.version}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold">{entry.version}</h2>
|
||||||
|
{entry.sections.map((section, si) => (
|
||||||
|
<div key={si} className="space-y-1.5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--primary)]">
|
||||||
|
{section.heading}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{section.items.map((item, ii) => (
|
||||||
|
<li
|
||||||
|
key={ii}
|
||||||
|
className="text-sm text-[var(--muted-foreground)] pl-3"
|
||||||
|
>
|
||||||
|
{"\u2022 "}
|
||||||
|
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,22 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||||
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
import type { CategoryBreakdownItem } from "../shared/types";
|
||||||
|
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
|
||||||
if (period === "all") return {};
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
let from: Date;
|
|
||||||
switch (period) {
|
|
||||||
case "month": from = new Date(year, month, 1); break;
|
|
||||||
case "3months": from = new Date(year, month - 2, 1); break;
|
|
||||||
case "6months": from = new Date(year, month - 5, 1); break;
|
|
||||||
case "12months": from = new Date(year, month - 11, 1); break;
|
|
||||||
}
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setPeriod } = useDashboard();
|
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
||||||
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -81,7 +65,9 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||||
|
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
|
@ -90,10 +76,16 @@ export default function DashboardPage() {
|
||||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
<PageHelp helpKey="dashboard" />
|
<PageHelp helpKey="dashboard" />
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector value={period} onChange={setPeriod} />
|
<PeriodSelector
|
||||||
|
value={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
customDateFrom={state.customDateFrom}
|
||||||
|
customDateTo={state.customDateTo}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<div
|
<div
|
||||||
key={card.labelKey}
|
key={card.labelKey}
|
||||||
|
|
@ -110,15 +102,48 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<CategoryPieChart
|
<div className="lg:col-span-1">
|
||||||
data={categoryBreakdown}
|
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
<CategoryPieChart
|
||||||
|
data={categoryBreakdown}
|
||||||
|
hiddenCategories={hiddenCategories}
|
||||||
|
onToggleHidden={toggleHidden}
|
||||||
|
onShowAll={showAll}
|
||||||
|
onViewDetails={viewDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
|
||||||
|
{t("reports.bva.titlePrefix")}
|
||||||
|
<select
|
||||||
|
value={`${state.budgetYear}-${state.budgetMonth}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [y, m] = e.target.value.split("-").map(Number);
|
||||||
|
setBudgetMonth(y, m);
|
||||||
|
}}
|
||||||
|
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{monthOptions.map((opt) => (
|
||||||
|
<option key={opt.key} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</h2>
|
||||||
|
<BudgetVsActualTable data={budgetVsActual} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
|
||||||
|
<CategoryOverTimeChart
|
||||||
|
data={categoryOverTime}
|
||||||
hiddenCategories={hiddenCategories}
|
hiddenCategories={hiddenCategories}
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
onShowAll={showAll}
|
onShowAll={showAll}
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
/>
|
||||||
<RecentTransactionsList transactions={recentTransactions} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ import {
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Footprints,
|
Footprints,
|
||||||
Printer,
|
Printer,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ key: "gettingStarted", icon: Rocket },
|
{ key: "gettingStarted", icon: Rocket },
|
||||||
|
{ key: "profiles", icon: Users },
|
||||||
{ key: "dashboard", icon: LayoutDashboard },
|
{ key: "dashboard", icon: LayoutDashboard },
|
||||||
{ key: "import", icon: Upload },
|
{ key: "import", icon: Upload },
|
||||||
{ key: "transactions", icon: ArrowLeftRight },
|
{ key: "transactions", icon: ArrowLeftRight },
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,40 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Hash, Table, BarChart3 } from "lucide-react";
|
||||||
import { useReports } from "../hooks/useReports";
|
import { useReports } from "../hooks/useReports";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
|
||||||
|
import { getAllSources } from "../services/importSourceService";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import MonthNavigator from "../components/budget/MonthNavigator";
|
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
|
import CategoryTable from "../components/reports/CategoryTable";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
|
import DynamicReport from "../components/reports/DynamicReport";
|
||||||
|
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
|
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||||
|
|
||||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
|
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
|
||||||
if (period === "all") return {};
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = now.getMonth();
|
|
||||||
const day = now.getDate();
|
|
||||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
||||||
let from: Date;
|
|
||||||
switch (period) {
|
|
||||||
case "month": from = new Date(year, month, 1); break;
|
|
||||||
case "3months": from = new Date(year, month - 2, 1); break;
|
|
||||||
case "6months": from = new Date(year, month - 5, 1); break;
|
|
||||||
case "12months": from = new Date(year, month - 11, 1); break;
|
|
||||||
}
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setTab, setPeriod, navigateBudgetMonth } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
||||||
|
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllSources().then(setSources);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true");
|
||||||
|
const [viewMode, setViewMode] = useState<"chart" | "table">(() =>
|
||||||
|
(localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart"
|
||||||
|
);
|
||||||
|
|
||||||
const toggleHidden = useCallback((name: string) => {
|
const toggleHidden = useCallback((name: string) => {
|
||||||
setHiddenCategories((prev) => {
|
setHiddenCategories((prev) => {
|
||||||
|
|
@ -53,27 +51,65 @@ export default function ReportsPage() {
|
||||||
setDetailModal(item);
|
setDetailModal(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(state.period);
|
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
|
const filterCategories = useMemo(() => {
|
||||||
|
if (state.tab === "byCategory") {
|
||||||
|
return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color }));
|
||||||
|
}
|
||||||
|
if (state.tab === "overTime") {
|
||||||
|
return state.categoryOverTime.categories.map((name) => ({
|
||||||
|
name,
|
||||||
|
color: state.categoryOverTime.colors[name] || "#9ca3af",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [state.tab, state.categorySpending, state.categoryOverTime]);
|
||||||
|
|
||||||
|
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||||
|
|
||||||
|
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
||||||
|
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
{state.tab === "budgetVsActual" ? (
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
|
||||||
|
{t("reports.bva.titlePrefix")}
|
||||||
|
<select
|
||||||
|
value={`${state.budgetYear}-${state.budgetMonth}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [y, m] = e.target.value.split("-").map(Number);
|
||||||
|
setBudgetMonth(y, m);
|
||||||
|
}}
|
||||||
|
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{monthOptions.map((opt) => (
|
||||||
|
<option key={opt.key} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</h1>
|
||||||
|
) : (
|
||||||
|
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||||
|
)}
|
||||||
<PageHelp helpKey="reports" />
|
<PageHelp helpKey="reports" />
|
||||||
</div>
|
</div>
|
||||||
{state.tab === "budgetVsActual" ? (
|
{state.tab !== "budgetVsActual" && (
|
||||||
<MonthNavigator
|
<PeriodSelector
|
||||||
year={state.budgetYear}
|
value={state.period}
|
||||||
month={state.budgetMonth}
|
onChange={setPeriod}
|
||||||
onNavigate={navigateBudgetMonth}
|
customDateFrom={state.customDateFrom}
|
||||||
|
customDateTo={state.customDateTo}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-6 flex-wrap">
|
<div className="flex gap-2 mb-6 flex-wrap items-center">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
|
|
@ -87,6 +123,54 @@ export default function ReportsPage() {
|
||||||
{t(`reports.${tab}`)}
|
{t(`reports.${tab}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{["trends", "byCategory", "overTime"].includes(state.tab) && (
|
||||||
|
<>
|
||||||
|
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
|
||||||
|
{([
|
||||||
|
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||||
|
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||||
|
]).map(({ mode, icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => {
|
||||||
|
setViewMode(mode);
|
||||||
|
localStorage.setItem("reports-view-mode", mode);
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
mode === viewMode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{viewMode === "chart" && (
|
||||||
|
<>
|
||||||
|
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAmounts((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem("reports-show-amounts", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
showAmounts
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||||
|
>
|
||||||
|
<Hash size={14} />
|
||||||
|
{showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
|
|
@ -95,28 +179,66 @@ export default function ReportsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
|
||||||
{state.tab === "byCategory" && (
|
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
|
||||||
<CategoryBarChart
|
{state.tab === "trends" && (
|
||||||
data={state.categorySpending}
|
viewMode === "chart" ? (
|
||||||
hiddenCategories={hiddenCategories}
|
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
|
||||||
onToggleHidden={toggleHidden}
|
) : (
|
||||||
onShowAll={showAll}
|
<MonthlyTrendsTable data={state.monthlyTrends} />
|
||||||
onViewDetails={viewDetails}
|
)
|
||||||
/>
|
)}
|
||||||
)}
|
{state.tab === "byCategory" && (
|
||||||
{state.tab === "overTime" && (
|
viewMode === "chart" ? (
|
||||||
<CategoryOverTimeChart
|
<CategoryBarChart
|
||||||
data={state.categoryOverTime}
|
data={state.categorySpending}
|
||||||
hiddenCategories={hiddenCategories}
|
hiddenCategories={hiddenCategories}
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
onShowAll={showAll}
|
onShowAll={showAll}
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
showAmounts={showAmounts}
|
||||||
)}
|
/>
|
||||||
{state.tab === "budgetVsActual" && (
|
) : (
|
||||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} />
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
|
{state.tab === "overTime" && (
|
||||||
|
viewMode === "chart" ? (
|
||||||
|
<CategoryOverTimeChart
|
||||||
|
data={state.categoryOverTime}
|
||||||
|
hiddenCategories={hiddenCategories}
|
||||||
|
onToggleHidden={toggleHidden}
|
||||||
|
onShowAll={showAll}
|
||||||
|
onViewDetails={viewDetails}
|
||||||
|
showAmounts={showAmounts}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CategoryOverTimeTable data={state.categoryOverTime} hiddenCategories={hiddenCategories} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{state.tab === "budgetVsActual" && (
|
||||||
|
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||||
|
)}
|
||||||
|
{state.tab === "dynamic" && (
|
||||||
|
<DynamicReport
|
||||||
|
config={state.pivotConfig}
|
||||||
|
result={state.pivotResult}
|
||||||
|
onConfigChange={setPivotConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showFilterPanel && (
|
||||||
|
<ReportFilterPanel
|
||||||
|
categories={filterCategories}
|
||||||
|
hiddenCategories={hiddenCategories}
|
||||||
|
onToggleHidden={toggleHidden}
|
||||||
|
onShowAll={showAll}
|
||||||
|
sources={sources}
|
||||||
|
selectedSourceId={state.sourceId}
|
||||||
|
onSourceChange={setSourceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<TransactionDetailModal
|
<TransactionDetailModal
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Info,
|
Info,
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { useUpdater } from "../hooks/useUpdater";
|
import { useUpdater } from "../hooks/useUpdater";
|
||||||
|
|
@ -18,17 +19,47 @@ import { Link } from "react-router-dom";
|
||||||
import { APP_NAME } from "../shared/constants";
|
import { APP_NAME } from "../shared/constants";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import DataManagementCard from "../components/settings/DataManagementCard";
|
import DataManagementCard from "../components/settings/DataManagementCard";
|
||||||
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||||
useUpdater();
|
useUpdater();
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
|
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchReleaseNotes = useCallback(
|
||||||
|
(targetVersion: string) => {
|
||||||
|
const file =
|
||||||
|
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
||||||
|
fetch(file)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => {
|
||||||
|
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp(
|
||||||
|
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
const match = text.match(re);
|
||||||
|
setReleaseNotes(
|
||||||
|
match ? match[1].trim() : null,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setReleaseNotes(null));
|
||||||
|
},
|
||||||
|
[i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion().then(setVersion);
|
getVersion().then(setVersion);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "available" && state.version) {
|
||||||
|
fetchReleaseNotes(state.version);
|
||||||
|
}
|
||||||
|
}, [state.status, state.version, fetchReleaseNotes]);
|
||||||
|
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
state.contentLength && state.contentLength > 0
|
state.contentLength && state.contentLength > 0
|
||||||
? Math.round((state.progress / state.contentLength) * 100)
|
? Math.round((state.progress / state.contentLength) * 100)
|
||||||
|
|
@ -77,6 +108,27 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Changelog card */}
|
||||||
|
<Link
|
||||||
|
to="/changelog"
|
||||||
|
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
||||||
|
<FileText size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("changelog.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Update card */}
|
{/* Update card */}
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
|
@ -125,6 +177,30 @@ export default function SettingsPage() {
|
||||||
<p>
|
<p>
|
||||||
{t("settings.updates.available", { version: state.version })}
|
{t("settings.updates.available", { version: state.version })}
|
||||||
</p>
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const notes = releaseNotes || state.body;
|
||||||
|
if (!notes) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
||||||
|
{t("settings.updates.releaseNotes")}
|
||||||
|
</h3>
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 text-sm text-[var(--muted-foreground)] space-y-1">
|
||||||
|
{notes.split("\n").map((line, i) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return <div key={i} className="h-2" />;
|
||||||
|
if (trimmed.startsWith("### "))
|
||||||
|
return <p key={i} className="font-semibold text-[var(--foreground)] mt-2">{trimmed.slice(4)}</p>;
|
||||||
|
if (trimmed.startsWith("## "))
|
||||||
|
return <p key={i} className="font-bold text-[var(--foreground)] mt-2">{trimmed.slice(3)}</p>;
|
||||||
|
if (trimmed.startsWith("- "))
|
||||||
|
return <p key={i} className="pl-3">{"\u2022 "}{trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, "$1")}</p>;
|
||||||
|
return <p key={i}>{trimmed}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={downloadAndInstall}
|
onClick={downloadAndInstall}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
|
@ -195,6 +271,9 @@ export default function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<LogViewerCard />
|
||||||
|
|
||||||
{/* Data management */}
|
{/* Data management */}
|
||||||
<DataManagementCard />
|
<DataManagementCard />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,16 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
||||||
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Actuals helpers ---
|
||||||
|
|
||||||
|
export async function getActualTotalsForYear(
|
||||||
|
year: number
|
||||||
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||||
|
const dateFrom = `${year}-01-01`;
|
||||||
|
const dateTo = `${year}-12-31`;
|
||||||
|
return getActualsByCategoryRange(dateFrom, dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Budget vs Actual ---
|
// --- Budget vs Actual ---
|
||||||
|
|
||||||
async function getActualsByCategoryRange(
|
async function getActualsByCategoryRange(
|
||||||
|
|
@ -231,7 +241,6 @@ export async function getBudgetVsActualData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index categories
|
// Index categories
|
||||||
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
|
||||||
const childrenByParent = new Map<number, Category[]>();
|
const childrenByParent = new Map<number, Category[]>();
|
||||||
for (const cat of allCategories) {
|
for (const cat of allCategories) {
|
||||||
if (cat.parent_id) {
|
if (cat.parent_id) {
|
||||||
|
|
@ -244,7 +253,7 @@ export async function getBudgetVsActualData(
|
||||||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||||
|
|
||||||
// Compute leaf row values
|
// Compute leaf row values
|
||||||
function buildLeaf(cat: Category, parentId: number | null): BudgetVsActualRow {
|
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
||||||
const sign = signFor(cat.type);
|
const sign = signFor(cat.type);
|
||||||
const monthMap = entryMap.get(cat.id);
|
const monthMap = entryMap.get(cat.id);
|
||||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||||
|
|
@ -269,6 +278,7 @@ export async function getBudgetVsActualData(
|
||||||
category_type: cat.type,
|
category_type: cat.type,
|
||||||
parent_id: parentId,
|
parent_id: parentId,
|
||||||
is_parent: false,
|
is_parent: false,
|
||||||
|
depth,
|
||||||
monthActual,
|
monthActual,
|
||||||
monthBudget,
|
monthBudget,
|
||||||
monthVariation,
|
monthVariation,
|
||||||
|
|
@ -280,6 +290,39 @@ export async function getBudgetVsActualData(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
|
||||||
|
const row: BudgetVsActualRow = {
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: cat.name,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: parentId,
|
||||||
|
is_parent: true,
|
||||||
|
depth,
|
||||||
|
monthActual: 0,
|
||||||
|
monthBudget: 0,
|
||||||
|
monthVariation: 0,
|
||||||
|
monthVariationPct: null,
|
||||||
|
ytdActual: 0,
|
||||||
|
ytdBudget: 0,
|
||||||
|
ytdVariation: 0,
|
||||||
|
ytdVariationPct: null,
|
||||||
|
};
|
||||||
|
for (const cr of childRows) {
|
||||||
|
row.monthActual += cr.monthActual;
|
||||||
|
row.monthBudget += cr.monthBudget;
|
||||||
|
row.monthVariation += cr.monthVariation;
|
||||||
|
row.ytdActual += cr.ytdActual;
|
||||||
|
row.ytdBudget += cr.ytdBudget;
|
||||||
|
row.ytdVariation += cr.ytdVariation;
|
||||||
|
}
|
||||||
|
row.monthVariationPct =
|
||||||
|
row.monthBudget !== 0 ? row.monthVariation / Math.abs(row.monthBudget) : null;
|
||||||
|
row.ytdVariationPct =
|
||||||
|
row.ytdBudget !== 0 ? row.ytdVariation / Math.abs(row.ytdBudget) : null;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function isRowAllZero(r: BudgetVsActualRow): boolean {
|
function isRowAllZero(r: BudgetVsActualRow): boolean {
|
||||||
return (
|
return (
|
||||||
r.monthActual === 0 &&
|
r.monthActual === 0 &&
|
||||||
|
|
@ -289,95 +332,99 @@ export async function getBudgetVsActualData(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
||||||
|
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
||||||
|
const subChildren = childrenByParent.get(cat.id) || [];
|
||||||
|
const hasSubChildren = subChildren.some(
|
||||||
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSubChildren && cat.is_inputable) {
|
||||||
|
const leaf = buildLeaf(cat, groupParentId, depth);
|
||||||
|
return isRowAllZero(leaf) ? [] : [leaf];
|
||||||
|
}
|
||||||
|
if (!hasSubChildren) return [];
|
||||||
|
|
||||||
|
const childRows: BudgetVsActualRow[] = [];
|
||||||
|
if (cat.is_inputable) {
|
||||||
|
const direct = buildLeaf(cat, cat.id, depth + 1);
|
||||||
|
direct.category_name = `${cat.name} (direct)`;
|
||||||
|
if (!isRowAllZero(direct)) childRows.push(direct);
|
||||||
|
}
|
||||||
|
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const child of sortedSubChildren) {
|
||||||
|
const grandchildren = childrenByParent.get(child.id) || [];
|
||||||
|
if (grandchildren.length > 0) {
|
||||||
|
const subRows = buildSubGroup(child, cat.id, depth + 1);
|
||||||
|
childRows.push(...subRows);
|
||||||
|
} else if (child.is_inputable) {
|
||||||
|
const leaf = buildLeaf(child, cat.id, depth + 1);
|
||||||
|
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (childRows.length === 0) return [];
|
||||||
|
|
||||||
|
const leafRows = childRows.filter((r) => !r.is_parent);
|
||||||
|
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
||||||
|
return [subtotal, ...childRows];
|
||||||
|
}
|
||||||
|
|
||||||
const rows: BudgetVsActualRow[] = [];
|
const rows: BudgetVsActualRow[] = [];
|
||||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
const topLevel = allCategories.filter((c) => !c.parent_id);
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
|
const hasChildren = children.some(
|
||||||
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
if (children.length === 0 && cat.is_inputable) {
|
if (!hasChildren && cat.is_inputable) {
|
||||||
// Standalone leaf
|
// Standalone leaf at level 0
|
||||||
const leaf = buildLeaf(cat, null);
|
const leaf = buildLeaf(cat, null, 0);
|
||||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||||
} else if (children.length > 0) {
|
} else if (hasChildren) {
|
||||||
const childRows: BudgetVsActualRow[] = [];
|
const allChildRows: BudgetVsActualRow[] = [];
|
||||||
|
|
||||||
// If parent is also inputable, create a "(direct)" child row
|
// Direct transactions on the parent itself
|
||||||
if (cat.is_inputable) {
|
if (cat.is_inputable) {
|
||||||
const direct = buildLeaf(cat, cat.id);
|
const direct = buildLeaf(cat, cat.id, 1);
|
||||||
direct.category_name = `${cat.name} (direct)`;
|
direct.category_name = `${cat.name} (direct)`;
|
||||||
if (!isRowAllZero(direct)) childRows.push(direct);
|
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of children) {
|
// Process children in alphabetical order
|
||||||
const leaf = buildLeaf(child, cat.id);
|
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
for (const child of sortedChildren) {
|
||||||
|
const grandchildren = childrenByParent.get(child.id) || [];
|
||||||
|
if (grandchildren.length > 0) {
|
||||||
|
const subRows = buildSubGroup(child, cat.id, 1);
|
||||||
|
allChildRows.push(...subRows);
|
||||||
|
} else if (child.is_inputable) {
|
||||||
|
const leaf = buildLeaf(child, cat.id, 1);
|
||||||
|
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip parent entirely if all children were filtered out
|
if (allChildRows.length === 0) continue;
|
||||||
if (childRows.length === 0) continue;
|
|
||||||
|
|
||||||
// Build parent subtotal from kept children
|
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||||
const parent: BudgetVsActualRow = {
|
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
||||||
category_id: cat.id,
|
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||||
category_name: cat.name,
|
|
||||||
category_color: cat.color || "#9ca3af",
|
|
||||||
category_type: cat.type,
|
|
||||||
parent_id: null,
|
|
||||||
is_parent: true,
|
|
||||||
monthActual: 0,
|
|
||||||
monthBudget: 0,
|
|
||||||
monthVariation: 0,
|
|
||||||
monthVariationPct: null,
|
|
||||||
ytdActual: 0,
|
|
||||||
ytdBudget: 0,
|
|
||||||
ytdVariation: 0,
|
|
||||||
ytdVariationPct: null,
|
|
||||||
};
|
|
||||||
for (const cr of childRows) {
|
|
||||||
parent.monthActual += cr.monthActual;
|
|
||||||
parent.monthBudget += cr.monthBudget;
|
|
||||||
parent.monthVariation += cr.monthVariation;
|
|
||||||
parent.ytdActual += cr.ytdActual;
|
|
||||||
parent.ytdBudget += cr.ytdBudget;
|
|
||||||
parent.ytdVariation += cr.ytdVariation;
|
|
||||||
}
|
|
||||||
parent.monthVariationPct =
|
|
||||||
parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null;
|
|
||||||
parent.ytdVariationPct =
|
|
||||||
parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null;
|
|
||||||
|
|
||||||
rows.push(parent);
|
rows.push(parent);
|
||||||
|
rows.push(...allChildRows);
|
||||||
// Sort children: "(direct)" first, then alphabetical
|
|
||||||
childRows.sort((a, b) => {
|
|
||||||
if (a.category_id === cat.id) return -1;
|
|
||||||
if (b.category_id === cat.id) return 1;
|
|
||||||
return a.category_name.localeCompare(b.category_name);
|
|
||||||
});
|
|
||||||
rows.push(...childRows);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by type, then within same type keep parent+children groups together
|
// Sort by type only, preserving tree order within groups (already built correctly)
|
||||||
|
const rowOrder = new Map<BudgetVsActualRow, number>();
|
||||||
|
rows.forEach((r, i) => rowOrder.set(r, i));
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
if (typeA !== typeB) return typeA - typeB;
|
if (typeA !== typeB) return typeA - typeB;
|
||||||
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id);
|
return rowOrder.get(a)! - rowOrder.get(b)!;
|
||||||
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id);
|
|
||||||
if (groupA !== groupB) {
|
|
||||||
const catA = catById.get(groupA);
|
|
||||||
const catB = catById.get(groupB);
|
|
||||||
const orderA = catA?.sort_order ?? 999;
|
|
||||||
const orderB = catB?.sort_order ?? 999;
|
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
|
||||||
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
|
|
||||||
}
|
|
||||||
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
|
|
||||||
if (a.parent_id && a.category_id === a.parent_id) return -1;
|
|
||||||
if (b.parent_id && b.category_id === b.parent_id) return 1;
|
|
||||||
return a.category_name.localeCompare(b.category_name);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,66 @@ function normalizeDescription(desc: string): string {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORD_CHAR = /\w/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a regex pattern for a keyword with smart boundaries.
|
||||||
|
* Uses \b when the keyword edge is a word character (a-z, 0-9, _),
|
||||||
|
* and uses (?<=\s|^) / (?=\s|$) when the edge is a non-word character
|
||||||
|
* (e.g., brackets, parentheses, dashes). This ensures keywords like
|
||||||
|
* "[VIREMENT]" or "(INTERAC)" can match correctly.
|
||||||
|
*/
|
||||||
|
function buildKeywordRegex(normalizedKeyword: string): RegExp {
|
||||||
|
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const left = WORD_CHAR.test(normalizedKeyword[0])
|
||||||
|
? "\\b"
|
||||||
|
: "(?<=\\s|^)";
|
||||||
|
const right = WORD_CHAR.test(normalizedKeyword[normalizedKeyword.length - 1])
|
||||||
|
? "\\b"
|
||||||
|
: "(?=\\s|$)";
|
||||||
|
return new RegExp(`${left}${escaped}${right}`);
|
||||||
|
}
|
||||||
|
|
||||||
interface CategorizationResult {
|
interface CategorizationResult {
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
supplier_id: number | null;
|
supplier_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompiledKeyword {
|
||||||
|
regex: RegExp;
|
||||||
|
category_id: number;
|
||||||
|
supplier_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile keywords into regex patterns once for reuse across multiple matches.
|
||||||
|
*/
|
||||||
|
function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
|
||||||
|
return keywords.map((kw) => ({
|
||||||
|
regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
|
||||||
|
category_id: kw.category_id,
|
||||||
|
supplier_id: kw.supplier_id ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a normalized description against compiled keywords.
|
||||||
|
*/
|
||||||
|
function matchDescription(
|
||||||
|
normalized: string,
|
||||||
|
compiled: CompiledKeyword[]
|
||||||
|
): CategorizationResult {
|
||||||
|
for (const kw of compiled) {
|
||||||
|
if (kw.regex.test(normalized)) {
|
||||||
|
return {
|
||||||
|
category_id: kw.category_id,
|
||||||
|
supplier_id: kw.supplier_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { category_id: null, supplier_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-categorize a single transaction description.
|
* Auto-categorize a single transaction description.
|
||||||
* Returns matching category_id and supplier_id, or nulls if no match.
|
* Returns matching category_id and supplier_id, or nulls if no match.
|
||||||
|
|
@ -33,20 +88,9 @@ export async function categorizeDescription(
|
||||||
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const compiled = compileKeywords(keywords);
|
||||||
const normalized = normalizeDescription(description);
|
const normalized = normalizeDescription(description);
|
||||||
|
return matchDescription(normalized, compiled);
|
||||||
for (const kw of keywords) {
|
|
||||||
const normalizedKeyword = normalizeDescription(kw.keyword);
|
|
||||||
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
|
|
||||||
return {
|
|
||||||
category_id: kw.category_id,
|
|
||||||
supplier_id: kw.supplier_id ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { category_id: null, supplier_id: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,18 +105,10 @@ export async function categorizeBatch(
|
||||||
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const compiled = compileKeywords(keywords);
|
||||||
|
|
||||||
return descriptions.map((desc) => {
|
return descriptions.map((desc) => {
|
||||||
const normalized = normalizeDescription(desc);
|
const normalized = normalizeDescription(desc);
|
||||||
for (const kw of keywords) {
|
return matchDescription(normalized, compiled);
|
||||||
const normalizedKeyword = normalizeDescription(kw.keyword);
|
|
||||||
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
|
|
||||||
return {
|
|
||||||
category_id: kw.category_id,
|
|
||||||
supplier_id: kw.supplier_id ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { category_id: null, supplier_id: null };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,22 @@ export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCategoryDepth(categoryId: number): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
let depth = 0;
|
||||||
|
let currentId: number | null = categoryId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
|
||||||
|
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
|
||||||
|
[currentId]
|
||||||
|
);
|
||||||
|
if (parentRows.length === 0 || parentRows[0].parent_id === null) break;
|
||||||
|
currentId = parentRows[0].parent_id;
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCategory(data: {
|
export async function createCategory(data: {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -35,10 +51,28 @@ export async function createCategory(data: {
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
// Validate max depth: parent at depth 2 would create a 4th level
|
||||||
|
if (data.parent_id !== null) {
|
||||||
|
const parentDepth = await getCategoryDepth(data.parent_id);
|
||||||
|
if (parentDepth >= 2) {
|
||||||
|
throw new Error("Cannot create category: maximum depth of 3 levels reached");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
|
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order]
|
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
|
||||||
|
if (data.parent_id !== null) {
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
|
||||||
|
[data.parent_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return result.lastInsertId as number;
|
return result.lastInsertId as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,16 +153,37 @@ export async function updateCategorySortOrders(
|
||||||
|
|
||||||
export async function deactivateCategory(id: number): Promise<void> {
|
export async function deactivateCategory(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
// Promote children to root level so they don't become orphans
|
// Remember the parent before deactivating
|
||||||
await db.execute(
|
const rows = await db.select<Array<{ parent_id: number | null }>>(
|
||||||
`UPDATE categories SET parent_id = NULL WHERE parent_id = $1`,
|
`SELECT parent_id FROM categories WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
const parentId = rows[0]?.parent_id ?? null;
|
||||||
|
|
||||||
|
// Promote children to parent level so they don't become orphans
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE categories SET parent_id = $1 WHERE parent_id = $2`,
|
||||||
|
[parentId, id]
|
||||||
|
);
|
||||||
// Only deactivate the target category itself
|
// Only deactivate the target category itself
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`UPDATE categories SET is_active = 0 WHERE id = $1`,
|
`UPDATE categories SET is_active = 0 WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-manage is_inputable: if parent now has no active children, restore is_inputable
|
||||||
|
if (parentId !== null) {
|
||||||
|
const childCount = await db.select<Array<{ cnt: number }>>(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM categories WHERE parent_id = $1 AND is_active = 1`,
|
||||||
|
[parentId]
|
||||||
|
);
|
||||||
|
if ((childCount[0]?.cnt ?? 0) === 0) {
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE categories SET is_inputable = 1 WHERE id = $1`,
|
||||||
|
[parentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategoryUsageCount(id: number): Promise<number> {
|
export async function getCategoryUsageCount(id: number): Promise<number> {
|
||||||
|
|
@ -142,9 +197,15 @@ export async function getCategoryUsageCount(id: number): Promise<number> {
|
||||||
|
|
||||||
export async function getChildrenUsageCount(parentId: number): Promise<number> {
|
export async function getChildrenUsageCount(parentId: number): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
// Check descendants recursively (up to 2 levels deep)
|
||||||
const rows = await db.select<Array<{ cnt: number }>>(
|
const rows = await db.select<Array<{ cnt: number }>>(
|
||||||
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN
|
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN (
|
||||||
(SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1)`,
|
SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1
|
||||||
|
UNION
|
||||||
|
SELECT id FROM categories WHERE parent_id IN (
|
||||||
|
SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1
|
||||||
|
) AND is_active = 1
|
||||||
|
)`,
|
||||||
[parentId]
|
[parentId]
|
||||||
);
|
);
|
||||||
return rows[0]?.cnt ?? 0;
|
return rows[0]?.cnt ?? 0;
|
||||||
|
|
@ -173,47 +234,61 @@ export async function reinitializeCategories(): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-seed child categories
|
// Re-seed child categories (level 2)
|
||||||
const children: Array<[number, string, number, string, string, number]> = [
|
// Note: Assurances (31) is now a non-inputable intermediate parent with level-3 children
|
||||||
[10, "Paie", 1, "income", "#22c55e", 1],
|
const children: Array<[number, string, number, string, string, number, boolean]> = [
|
||||||
[11, "Autres revenus", 1, "income", "#4ade80", 2],
|
[10, "Paie", 1, "income", "#22c55e", 1, true],
|
||||||
[20, "Loyer", 2, "expense", "#ef4444", 1],
|
[11, "Autres revenus", 1, "income", "#4ade80", 2, true],
|
||||||
[21, "Électricité", 2, "expense", "#f59e0b", 2],
|
[20, "Loyer", 2, "expense", "#ef4444", 1, true],
|
||||||
[22, "Épicerie", 2, "expense", "#10b981", 3],
|
[21, "Électricité", 2, "expense", "#f59e0b", 2, true],
|
||||||
[23, "Dons", 2, "expense", "#ec4899", 4],
|
[22, "Épicerie", 2, "expense", "#10b981", 3, true],
|
||||||
[24, "Restaurant", 2, "expense", "#f97316", 5],
|
[23, "Dons", 2, "expense", "#ec4899", 4, true],
|
||||||
[25, "Frais bancaires", 2, "expense", "#6b7280", 6],
|
[24, "Restaurant", 2, "expense", "#f97316", 5, true],
|
||||||
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7],
|
[25, "Frais bancaires", 2, "expense", "#6b7280", 6, true],
|
||||||
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8],
|
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7, true],
|
||||||
[28, "Transport en commun", 2, "expense", "#3b82f6", 9],
|
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8, true],
|
||||||
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10],
|
[28, "Transport en commun", 2, "expense", "#3b82f6", 9, true],
|
||||||
[30, "Animaux", 2, "expense", "#a855f7", 11],
|
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10, true],
|
||||||
[31, "Assurances", 2, "expense", "#14b8a6", 12],
|
[30, "Animaux", 2, "expense", "#a855f7", 11, true],
|
||||||
[32, "Pharmacie", 2, "expense", "#f43f5e", 13],
|
[31, "Assurances", 2, "expense", "#14b8a6", 12, false], // intermediate parent
|
||||||
[33, "Taxes municipales", 2, "expense", "#78716c", 14],
|
[32, "Pharmacie", 2, "expense", "#f43f5e", 13, true],
|
||||||
[40, "Voiture", 3, "expense", "#64748b", 1],
|
[33, "Taxes municipales", 2, "expense", "#78716c", 14, true],
|
||||||
[41, "Amazon", 3, "expense", "#f59e0b", 2],
|
[40, "Voiture", 3, "expense", "#64748b", 1, true],
|
||||||
[42, "Électroniques", 3, "expense", "#3b82f6", 3],
|
[41, "Amazon", 3, "expense", "#f59e0b", 2, true],
|
||||||
[43, "Alcool", 3, "expense", "#7c3aed", 4],
|
[42, "Électroniques", 3, "expense", "#3b82f6", 3, true],
|
||||||
[44, "Cadeaux", 3, "expense", "#ec4899", 5],
|
[43, "Alcool", 3, "expense", "#7c3aed", 4, true],
|
||||||
[45, "Vêtements", 3, "expense", "#d946ef", 6],
|
[44, "Cadeaux", 3, "expense", "#ec4899", 5, true],
|
||||||
[46, "CPA", 3, "expense", "#0ea5e9", 7],
|
[45, "Vêtements", 3, "expense", "#d946ef", 6, true],
|
||||||
[47, "Voyage", 3, "expense", "#f97316", 8],
|
[46, "CPA", 3, "expense", "#0ea5e9", 7, true],
|
||||||
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9],
|
[47, "Voyage", 3, "expense", "#f97316", 8, true],
|
||||||
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10],
|
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9, true],
|
||||||
[50, "Hypothèque", 4, "expense", "#dc2626", 1],
|
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10, true],
|
||||||
[51, "Achats maison", 4, "expense", "#ea580c", 2],
|
[50, "Hypothèque", 4, "expense", "#dc2626", 1, true],
|
||||||
[52, "Entretien maison", 4, "expense", "#ca8a04", 3],
|
[51, "Achats maison", 4, "expense", "#ea580c", 2, true],
|
||||||
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4],
|
[52, "Entretien maison", 4, "expense", "#ca8a04", 3, true],
|
||||||
[54, "Outils", 4, "expense", "#b45309", 5],
|
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4, true],
|
||||||
[60, "Placements", 5, "transfer", "#2563eb", 1],
|
[54, "Outils", 4, "expense", "#b45309", 5, true],
|
||||||
[61, "Transferts", 5, "transfer", "#7c3aed", 2],
|
[60, "Placements", 5, "transfer", "#2563eb", 1, true],
|
||||||
[70, "Impôts", 6, "expense", "#dc2626", 1],
|
[61, "Transferts", 5, "transfer", "#7c3aed", 2, true],
|
||||||
[71, "Paiement CC", 6, "transfer", "#6b7280", 2],
|
[70, "Impôts", 6, "expense", "#dc2626", 1, true],
|
||||||
[72, "Retrait cash", 6, "expense", "#57534e", 3],
|
[71, "Paiement CC", 6, "transfer", "#6b7280", 2, true],
|
||||||
[73, "Projets", 6, "expense", "#0ea5e9", 4],
|
[72, "Retrait cash", 6, "expense", "#57534e", 3, true],
|
||||||
|
[73, "Projets", 6, "expense", "#0ea5e9", 4, true],
|
||||||
];
|
];
|
||||||
for (const [id, name, parentId, type, color, sort] of children) {
|
for (const [id, name, parentId, type, color, sort, inputable] of children) {
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||||
|
[id, name, parentId, type, color, sort, inputable ? 1 : 0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-seed grandchild categories (level 3) — under Assurances (31)
|
||||||
|
const grandchildren: Array<[number, string, number, string, string, number]> = [
|
||||||
|
[310, "Assurance-auto", 31, "expense", "#14b8a6", 1],
|
||||||
|
[311, "Assurance-habitation", 31, "expense", "#0d9488", 2],
|
||||||
|
[312, "Assurance-vie", 31, "expense", "#0f766e", 3],
|
||||||
|
];
|
||||||
|
for (const [id, name, parentId, type, color, sort] of grandchildren) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)",
|
"INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
[id, name, parentId, type, color, sort]
|
[id, name, parentId, type, color, sort]
|
||||||
|
|
@ -237,7 +312,7 @@ export async function reinitializeCategories(): Promise<void> {
|
||||||
["GARE CENTRALE", 28], ["REM", 28],
|
["GARE CENTRALE", 28], ["REM", 28],
|
||||||
["VIDEOTRON", 29], ["ORICOM", 29],
|
["VIDEOTRON", 29], ["ORICOM", 29],
|
||||||
["MONDOU", 30],
|
["MONDOU", 30],
|
||||||
["BELAIR", 31], ["PRYSM", 31], ["INS/ASS", 31],
|
["BELAIR", 310], ["PRYSM", 311], ["INS/ASS", 312],
|
||||||
["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32],
|
["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32],
|
||||||
["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33],
|
["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33],
|
||||||
["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40],
|
["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40],
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ export async function getDashboardSummary(
|
||||||
|
|
||||||
export async function getExpensesByCategory(
|
export async function getExpensesByCategory(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string
|
dateTo?: string,
|
||||||
|
sourceId?: number,
|
||||||
): Promise<CategoryBreakdownItem[]> {
|
): Promise<CategoryBreakdownItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -71,6 +72,11 @@ export async function getExpensesByCategory(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
if (sourceId != null) {
|
||||||
|
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||||
|
params.push(sourceId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Database from "@tauri-apps/plugin-sql";
|
import Database from "@tauri-apps/plugin-sql";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
let dbInstance: Database | null = null;
|
let dbInstance: Database | null = null;
|
||||||
|
|
||||||
|
|
@ -14,6 +15,15 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
|
||||||
await dbInstance.close();
|
await dbInstance.close();
|
||||||
dbInstance = null;
|
dbInstance = null;
|
||||||
}
|
}
|
||||||
|
// Repair migration checksums before loading (fixes "migration was modified" error)
|
||||||
|
try {
|
||||||
|
const repaired = await invoke<boolean>("repair_migrations", { dbFilename });
|
||||||
|
if (repaired) {
|
||||||
|
console.warn("Migration checksums repaired for", dbFilename);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Migration repair failed:", e);
|
||||||
|
}
|
||||||
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
98
src/services/logService.ts
Normal file
98
src/services/logService.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
export type LogLevel = "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogListener = () => void;
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 500;
|
||||||
|
const STORAGE_KEY = "simpl-resultat-logs";
|
||||||
|
const logs: LogEntry[] = [];
|
||||||
|
const listeners = new Set<LogListener>();
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed: LogEntry[] = JSON.parse(stored);
|
||||||
|
logs.push(...parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupted storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToStorage() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntry(level: LogLevel, args: unknown[]) {
|
||||||
|
const message = args
|
||||||
|
.map((a) => {
|
||||||
|
if (typeof a === "string") return a;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
logs.push({ timestamp: Date.now(), level, message });
|
||||||
|
if (logs.length > MAX_ENTRIES) {
|
||||||
|
logs.splice(0, logs.length - MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToStorage();
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initLogCapture() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
loadFromStorage();
|
||||||
|
|
||||||
|
const origLog = console.log.bind(console);
|
||||||
|
const origWarn = console.warn.bind(console);
|
||||||
|
const origError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
addEntry("info", args);
|
||||||
|
origLog(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
addEntry("warn", args);
|
||||||
|
origWarn(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
addEntry("error", args);
|
||||||
|
origError(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogs(): readonly LogEntry[] {
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLogs() {
|
||||||
|
logs.length = 0;
|
||||||
|
saveToStorage();
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: LogListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,16 @@ import type {
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
CategoryOverTimeItem,
|
CategoryOverTimeItem,
|
||||||
|
PivotConfig,
|
||||||
|
PivotFieldId,
|
||||||
|
PivotResult,
|
||||||
|
PivotResultRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
export async function getMonthlyTrends(
|
export async function getMonthlyTrends(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string
|
dateTo?: string,
|
||||||
|
sourceId?: number,
|
||||||
): Promise<MonthlyTrendItem[]> {
|
): Promise<MonthlyTrendItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -26,6 +31,11 @@ export async function getMonthlyTrends(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
if (sourceId != null) {
|
||||||
|
whereClauses.push(`source_id = $${paramIndex}`);
|
||||||
|
params.push(sourceId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
const whereSQL =
|
const whereSQL =
|
||||||
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
@ -46,7 +56,8 @@ export async function getMonthlyTrends(
|
||||||
export async function getCategoryOverTime(
|
export async function getCategoryOverTime(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 8
|
topN: number = 50,
|
||||||
|
sourceId?: number,
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -64,6 +75,11 @@ export async function getCategoryOverTime(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
if (sourceId != null) {
|
||||||
|
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||||
|
params.push(sourceId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
||||||
|
|
@ -147,3 +163,189 @@ export async function getCategoryOverTime(
|
||||||
categoryIds,
|
categoryIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dynamic Report (Pivot Table) ---
|
||||||
|
|
||||||
|
const FIELD_SQL: Record<PivotFieldId, { select: string; alias: string }> = {
|
||||||
|
year: { select: "strftime('%Y', t.date)", alias: "year" },
|
||||||
|
month: { select: "strftime('%Y-%m', t.date)", alias: "month" },
|
||||||
|
type: { select: "COALESCE(c.type, 'expense')", alias: "type" },
|
||||||
|
level1: { select: "COALESCE(grandparent_cat.name, parent_cat.name, c.name, 'Uncategorized')", alias: "level1" },
|
||||||
|
level2: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN parent_cat.name WHEN parent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level2" },
|
||||||
|
level3: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level3" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function needsCategoryJoin(fields: PivotFieldId[]): boolean {
|
||||||
|
return fields.some((f) => f === "type" || f === "level1" || f === "level2" || f === "level3");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDynamicReportData(
|
||||||
|
config: PivotConfig,
|
||||||
|
): Promise<PivotResult> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const allDimensions = [...config.rows, ...config.columns];
|
||||||
|
const filterFields = Object.keys(config.filters) as PivotFieldId[];
|
||||||
|
const allFields = [...new Set([...allDimensions, ...filterFields])];
|
||||||
|
|
||||||
|
const useCatJoin = needsCategoryJoin(allFields);
|
||||||
|
|
||||||
|
// Build SELECT columns
|
||||||
|
const selectParts: string[] = [];
|
||||||
|
const groupByParts: string[] = [];
|
||||||
|
|
||||||
|
for (const fieldId of allDimensions) {
|
||||||
|
const def = FIELD_SQL[fieldId];
|
||||||
|
selectParts.push(`${def.select} AS ${def.alias}`);
|
||||||
|
groupByParts.push(def.alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measures
|
||||||
|
const hasPeriodic = config.values.includes("periodic");
|
||||||
|
const hasYtd = config.values.includes("ytd");
|
||||||
|
|
||||||
|
if (hasPeriodic) {
|
||||||
|
selectParts.push("ABS(SUM(t.amount)) AS periodic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE
|
||||||
|
const whereClauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Apply filter values (include / exclude)
|
||||||
|
for (const fieldId of filterFields) {
|
||||||
|
const entry = config.filters[fieldId];
|
||||||
|
if (!entry) continue;
|
||||||
|
const def = FIELD_SQL[fieldId as PivotFieldId];
|
||||||
|
if (entry.include && entry.include.length > 0) {
|
||||||
|
const placeholders = entry.include.map(() => {
|
||||||
|
const p = `$${paramIndex}`;
|
||||||
|
paramIndex++;
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`);
|
||||||
|
params.push(...entry.include);
|
||||||
|
}
|
||||||
|
if (entry.exclude && entry.exclude.length > 0) {
|
||||||
|
const placeholders = entry.exclude.map(() => {
|
||||||
|
const p = `$${paramIndex}`;
|
||||||
|
paramIndex++;
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
whereClauses.push(`${def.select} NOT IN (${placeholders.join(", ")})`);
|
||||||
|
params.push(...entry.exclude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
const groupBySQL = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(", ")}` : "";
|
||||||
|
const orderBySQL = groupByParts.length > 0 ? `ORDER BY ${groupByParts.join(", ")}` : "";
|
||||||
|
|
||||||
|
const joinSQL = useCatJoin
|
||||||
|
? `LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id
|
||||||
|
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const sql = `SELECT ${selectParts.join(", ")}
|
||||||
|
FROM transactions t
|
||||||
|
${joinSQL}
|
||||||
|
${whereSQL}
|
||||||
|
${groupBySQL}
|
||||||
|
${orderBySQL}`;
|
||||||
|
|
||||||
|
const rawRows = await db.select<Array<Record<string, unknown>>>(sql, params);
|
||||||
|
|
||||||
|
// Build PivotResultRow array
|
||||||
|
const rows: PivotResultRow[] = rawRows.map((raw) => {
|
||||||
|
const keys: Record<string, string> = {};
|
||||||
|
for (const fieldId of allDimensions) {
|
||||||
|
keys[fieldId] = String(raw[FIELD_SQL[fieldId].alias] ?? "");
|
||||||
|
}
|
||||||
|
const measures: Record<string, number> = {};
|
||||||
|
if (hasPeriodic) {
|
||||||
|
measures.periodic = Number(raw.periodic) || 0;
|
||||||
|
}
|
||||||
|
return { keys, measures };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute YTD if requested
|
||||||
|
if (hasYtd && rows.length > 0) {
|
||||||
|
// YTD = cumulative sum from January of the year, grouped by row dimensions (excluding month)
|
||||||
|
const rowDims = config.rows.filter((f) => f !== "month");
|
||||||
|
const colDims = config.columns.filter((f) => f !== "month");
|
||||||
|
const groupDims = [...rowDims, ...colDims];
|
||||||
|
|
||||||
|
// Sort rows by year then month for accumulation
|
||||||
|
const sorted = [...rows].sort((a, b) => {
|
||||||
|
const aKey = (a.keys.year || a.keys.month?.slice(0, 4) || "") + (a.keys.month || "");
|
||||||
|
const bKey = (b.keys.year || b.keys.month?.slice(0, 4) || "") + (b.keys.month || "");
|
||||||
|
return aKey.localeCompare(bKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accumulate by group key + year
|
||||||
|
const accumulators = new Map<string, number>();
|
||||||
|
for (const row of sorted) {
|
||||||
|
const year = row.keys.year || row.keys.month?.slice(0, 4) || "";
|
||||||
|
const groupKey = groupDims.map((d) => row.keys[d] || "").join("|") + "|" + year;
|
||||||
|
const prev = accumulators.get(groupKey) || 0;
|
||||||
|
const current = prev + (row.measures.periodic || 0);
|
||||||
|
accumulators.set(groupKey, current);
|
||||||
|
row.measures.ytd = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original order
|
||||||
|
const rowMap = new Map(sorted.map((r) => {
|
||||||
|
const key = Object.values(r.keys).join("|");
|
||||||
|
return [key, r];
|
||||||
|
}));
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const key = Object.values(rows[i].keys).join("|");
|
||||||
|
const updated = rowMap.get(key);
|
||||||
|
if (updated) {
|
||||||
|
rows[i].measures.ytd = updated.measures.ytd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract distinct column values (composite key when multiple column dimensions)
|
||||||
|
const colDims = config.columns;
|
||||||
|
const columnValues = colDims.length > 0
|
||||||
|
? [...new Set(rows.map((r) => colDims.map((d) => r.keys[d] || "").join("\0")))].sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Dimension labels
|
||||||
|
const dimensionLabels: Record<string, string> = {
|
||||||
|
year: "Année",
|
||||||
|
month: "Mois",
|
||||||
|
type: "Type",
|
||||||
|
level1: "Catégorie (Niveau 1)",
|
||||||
|
level2: "Catégorie (Niveau 2)",
|
||||||
|
level3: "Catégorie (Niveau 3)",
|
||||||
|
periodic: "Montant périodique",
|
||||||
|
ytd: "Cumul annuel (YTD)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { rows, columnValues, dimensionLabels };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDynamicFilterValues(
|
||||||
|
fieldId: PivotFieldId,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const def = FIELD_SQL[fieldId];
|
||||||
|
const useCatJoin = needsCategoryJoin([fieldId]);
|
||||||
|
|
||||||
|
const joinSQL = useCatJoin
|
||||||
|
? `LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id
|
||||||
|
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const rows = await db.select<Array<{ val: string }>>(
|
||||||
|
`SELECT DISTINCT ${def.select} AS val FROM transactions t ${joinSQL} ORDER BY val`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.val);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,10 @@ export interface BudgetYearRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
|
depth?: number;
|
||||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||||
annual: number; // computed sum
|
annual: number; // computed sum
|
||||||
|
previousYearTotal: number; // actual (transactions) total from the previous year
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportConfigTemplate {
|
export interface ImportConfigTemplate {
|
||||||
|
|
@ -247,7 +249,7 @@ export interface ImportReport {
|
||||||
|
|
||||||
// --- Dashboard Types ---
|
// --- Dashboard Types ---
|
||||||
|
|
||||||
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all";
|
export type DashboardPeriod = "month" | "3months" | "6months" | "year" | "12months" | "all" | "custom";
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
@ -274,7 +276,36 @@ export interface RecentTransaction {
|
||||||
|
|
||||||
// --- Report Types ---
|
// --- Report Types ---
|
||||||
|
|
||||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual" | "dynamic";
|
||||||
|
|
||||||
|
// --- Pivot / Dynamic Report Types ---
|
||||||
|
|
||||||
|
export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2" | "level3";
|
||||||
|
export type PivotMeasureId = "periodic" | "ytd";
|
||||||
|
export type PivotZone = "rows" | "columns" | "filters" | "values";
|
||||||
|
|
||||||
|
export interface PivotFilterEntry {
|
||||||
|
include: string[]; // included values (empty = all)
|
||||||
|
exclude: string[]; // excluded values
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PivotConfig {
|
||||||
|
rows: PivotFieldId[];
|
||||||
|
columns: PivotFieldId[];
|
||||||
|
filters: Record<string, PivotFilterEntry>; // field → include/exclude entries
|
||||||
|
values: PivotMeasureId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PivotResultRow {
|
||||||
|
keys: Record<string, string>; // dimension values
|
||||||
|
measures: Record<string, number>; // measure values
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PivotResult {
|
||||||
|
rows: PivotResultRow[];
|
||||||
|
columnValues: string[]; // distinct values for column dimension
|
||||||
|
dimensionLabels: Record<string, string>; // field id → display label
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonthlyTrendItem {
|
export interface MonthlyTrendItem {
|
||||||
month: string; // "2025-01"
|
month: string; // "2025-01"
|
||||||
|
|
@ -301,6 +332,7 @@ export interface BudgetVsActualRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
|
depth?: number;
|
||||||
monthActual: number;
|
monthActual: number;
|
||||||
monthBudget: number;
|
monthBudget: number;
|
||||||
monthVariation: number;
|
monthVariation: number;
|
||||||
|
|
|
||||||
123
src/utils/dateRange.test.ts
Normal file
123
src/utils/dateRange.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { computeDateRange, buildMonthOptions } from "./dateRange";
|
||||||
|
|
||||||
|
describe("computeDateRange", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Fix "now" to 2025-07-15 for deterministic tests
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object for "all" period', () => {
|
||||||
|
expect(computeDateRange("all")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns custom range for "custom" period', () => {
|
||||||
|
expect(computeDateRange("custom", "2025-01-01", "2025-06-30")).toEqual({
|
||||||
|
dateFrom: "2025-01-01",
|
||||||
|
dateTo: "2025-06-30",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default when "custom" has missing dates', () => {
|
||||||
|
const result = computeDateRange("custom");
|
||||||
|
// Should fall through to default (same as "month")
|
||||||
|
expect(result.dateFrom).toBe("2025-07-01");
|
||||||
|
expect(result.dateTo).toBe("2025-07-15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes "month" period (first of current month to today)', () => {
|
||||||
|
const result = computeDateRange("month");
|
||||||
|
expect(result).toEqual({ dateFrom: "2025-07-01", dateTo: "2025-07-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes "3months" period (3 months back)', () => {
|
||||||
|
const result = computeDateRange("3months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2025-05-01", dateTo: "2025-07-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes "6months" period (6 months back)', () => {
|
||||||
|
const result = computeDateRange("6months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2025-02-01", dateTo: "2025-07-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes "year" period (Jan 1st of current year)', () => {
|
||||||
|
const result = computeDateRange("year");
|
||||||
|
expect(result).toEqual({ dateFrom: "2025-01-01", dateTo: "2025-07-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes "12months" period (12 months back)', () => {
|
||||||
|
const result = computeDateRange("12months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-07-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles January rollover for 3months period", () => {
|
||||||
|
vi.setSystemTime(new Date(2025, 1, 10)); // Feb 10, 2025
|
||||||
|
const result = computeDateRange("3months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2024-12-01", dateTo: "2025-02-10" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles January rollover for 6months period", () => {
|
||||||
|
vi.setSystemTime(new Date(2025, 0, 20)); // Jan 20, 2025
|
||||||
|
const result = computeDateRange("6months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-01-20" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles January rollover for 12months period", () => {
|
||||||
|
vi.setSystemTime(new Date(2025, 0, 5)); // Jan 5, 2025
|
||||||
|
const result = computeDateRange("12months");
|
||||||
|
expect(result).toEqual({ dateFrom: "2024-02-01", dateTo: "2025-01-05" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildMonthOptions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 24 month options", () => {
|
||||||
|
const options = buildMonthOptions("fr");
|
||||||
|
expect(options).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with the current month", () => {
|
||||||
|
const options = buildMonthOptions("en");
|
||||||
|
expect(options[0].key).toBe("2025-7");
|
||||||
|
expect(options[0].value).toBe("2025-7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends 23 months ago", () => {
|
||||||
|
const options = buildMonthOptions("en");
|
||||||
|
expect(options[23].key).toBe("2023-8");
|
||||||
|
expect(options[23].value).toBe("2023-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles January rollover correctly", () => {
|
||||||
|
vi.setSystemTime(new Date(2025, 0, 15)); // Jan 15, 2025
|
||||||
|
const options = buildMonthOptions("en");
|
||||||
|
expect(options[0].key).toBe("2025-1");
|
||||||
|
expect(options[1].key).toBe("2024-12");
|
||||||
|
expect(options[12].key).toBe("2024-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capitalizes the first letter of labels", () => {
|
||||||
|
const options = buildMonthOptions("fr");
|
||||||
|
for (const opt of options) {
|
||||||
|
expect(opt.label[0]).toBe(opt.label[0].toUpperCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels contain year information", () => {
|
||||||
|
const options = buildMonthOptions("en");
|
||||||
|
expect(options[0].label).toContain("2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/utils/dateRange.ts
Normal file
66
src/utils/dateRange.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { DashboardPeriod } from "../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a date range (dateFrom / dateTo) based on the selected period.
|
||||||
|
* Shared between useDashboard, useReports, DashboardPage and ReportsPage.
|
||||||
|
*/
|
||||||
|
export function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month":
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
case "3months":
|
||||||
|
from = new Date(year, month - 2, 1);
|
||||||
|
break;
|
||||||
|
case "6months":
|
||||||
|
from = new Date(year, month - 5, 1);
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
from = new Date(year, 0, 1);
|
||||||
|
break;
|
||||||
|
case "12months":
|
||||||
|
from = new Date(year, month - 11, 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an array of month options for the budget month dropdown.
|
||||||
|
* Returns the last 24 months with localized labels.
|
||||||
|
*/
|
||||||
|
export function buildMonthOptions(language: string): Array<{ key: string; value: string; label: string }> {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
return Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const d = new Date(currentYear, currentMonth - i, 1);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = d.getMonth() + 1;
|
||||||
|
const label = new Intl.DateTimeFormat(language, { month: "long", year: "numeric" }).format(d);
|
||||||
|
return { key: `${y}-${m}`, value: `${y}-${m}`, label: label.charAt(0).toUpperCase() + label.slice(1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
38
src/utils/reorderRows.ts
Normal file
38
src/utils/reorderRows.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Shared utility for reordering budget table rows.
|
||||||
|
* Recursively moves subtotal (parent) rows below their children
|
||||||
|
* at every depth level when "subtotals on bottom" is enabled.
|
||||||
|
*/
|
||||||
|
export function reorderRows<
|
||||||
|
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
|
||||||
|
>(rows: T[], subtotalsOnTop: boolean): T[] {
|
||||||
|
if (subtotalsOnTop) return rows;
|
||||||
|
|
||||||
|
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
|
||||||
|
const result: T[] = [];
|
||||||
|
let currentParent: T | null = null;
|
||||||
|
let currentChildren: T[] = [];
|
||||||
|
|
||||||
|
for (const row of groupRows) {
|
||||||
|
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
|
||||||
|
// Flush previous group
|
||||||
|
if (currentParent) {
|
||||||
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
|
currentChildren = [];
|
||||||
|
}
|
||||||
|
currentParent = row;
|
||||||
|
} else if (currentParent) {
|
||||||
|
currentChildren.push(row);
|
||||||
|
} else {
|
||||||
|
result.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush last group
|
||||||
|
if (currentParent) {
|
||||||
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reorderGroup(rows, 0);
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,53 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { copyFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
// Sync root CHANGELOG files to public/ so the app always shows the latest version history
|
||||||
|
function syncChangelogs() {
|
||||||
|
const root = import.meta.dirname;
|
||||||
|
const files = ["CHANGELOG.md", "CHANGELOG.fr.md"];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
copyFileSync(resolve(root, file), resolve(root, "public", file));
|
||||||
|
} catch {
|
||||||
|
// Ignore if source file doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => {
|
||||||
plugins: [react(), tailwindcss()],
|
// Sync changelogs before starting dev server or building
|
||||||
|
syncChangelogs();
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
return {
|
||||||
//
|
plugins: [react(), tailwindcss()],
|
||||||
// 1. prevent Vite from obscuring rust errors
|
|
||||||
clearScreen: false,
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
//
|
||||||
server: {
|
// 1. prevent Vite from obscuring rust errors
|
||||||
port: 1420,
|
clearScreen: false,
|
||||||
strictPort: true,
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
host: host || false,
|
server: {
|
||||||
hmr: host
|
port: 1420,
|
||||||
? {
|
strictPort: true,
|
||||||
protocol: "ws",
|
host: host || false,
|
||||||
host,
|
hmr: host
|
||||||
port: 1421,
|
? {
|
||||||
}
|
protocol: "ws",
|
||||||
: undefined,
|
host,
|
||||||
watch: {
|
port: 1421,
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
}
|
||||||
ignored: ["**/src-tauri/**"],
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
}));
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue