Compare commits
151 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 | ||
|
|
b190df4eae | ||
|
|
0b8d469699 | ||
|
|
4b2b45bf6f | ||
|
|
2cd84ca041 | ||
|
|
142c240a00 | ||
|
|
c7baf85cbb | ||
|
|
404478ff65 | ||
|
|
732302cb44 | ||
|
|
20cae64f60 | ||
|
|
0831663bbd | ||
|
|
9914737f26 | ||
|
|
5e7c7e6609 | ||
|
|
32dae2b7b2 | ||
|
|
f9c6fabc13 | ||
|
|
13989dc0b8 | ||
|
|
5bfab3175e | ||
|
|
de78b54d41 | ||
|
|
e23e559ee3 | ||
|
|
172be36f1d | ||
|
|
ac295d9048 | ||
|
|
981291f048 | ||
|
|
db1d47ea94 | ||
|
|
7e12f8c911 | ||
|
|
87e8f26754 | ||
|
|
d6e6ce1136 | ||
|
|
c7f7bab98f | ||
|
|
86f3d88be9 | ||
|
|
9ed79b4fa3 | ||
|
|
61423fc362 | ||
|
|
ccdab1f06a | ||
|
|
720f52bad6 | ||
|
|
29a1a15120 |
116 changed files with 14771 additions and 1138 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
|
||||
115
.github/workflows/release.yml
vendored
115
.github/workflows/release.yml
vendored
|
|
@ -9,16 +9,8 @@ permissions:
|
|||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-latest
|
||||
# - platform: ubuntu-22.04
|
||||
# - platform: macos-latest
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -41,6 +33,25 @@ jobs:
|
|||
- 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:
|
||||
|
|
@ -51,12 +62,94 @@ jobs:
|
|||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
||||
releaseBody: |
|
||||
${{ steps.changelog.outputs.notes }}
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
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.
|
||||
> 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:
|
||||
- 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 Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
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
|
||||
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
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -23,8 +23,13 @@ imports/*.csv
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Secrets
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
|
@ -40,5 +45,9 @@ imports/*.csv
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Auto-generated changelogs (synced from root by vite.config.ts)
|
||||
public/CHANGELOG.md
|
||||
public/CHANGELOG.fr.md
|
||||
|
||||
# Tauri generated
|
||||
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
|
||||
267
CHANGELOG.md
Normal file
267
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# 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
|
||||
|
||||
### New Features
|
||||
- **Chart patterns**: Added SVG fill patterns (diagonal lines, dots, crosshatch, etc.) to differentiate categories in bar charts, pie chart, and stacked bar charts beyond just color
|
||||
- **Chart context menu**: Right-click any category in a chart to hide it or view its transactions in a detail popup
|
||||
- **Hidden categories**: Hidden categories appear as dismissible chips above charts with a "Show all" button to restore them
|
||||
- **Transaction detail modal**: View all transactions composing a category's total directly from any chart
|
||||
- **Import preview popup**: The data preview is now a popup modal instead of a separate wizard step, allowing quick inspection without leaving the configuration page
|
||||
- **Direct duplicate check**: New "Check Duplicates" button on the import configuration page skips directly to duplicate validation without requiring a preview first
|
||||
|
||||
### Improvements
|
||||
- Import wizard flow simplified: source-config → duplicate-check (preview is optional via popup)
|
||||
- Duplicate-check back button now returns to source configuration instead of the removed preview step
|
||||
- Added `categoryIds` map to `CategoryOverTimeData` for proper category resolution in the over-time chart
|
||||
|
||||
## 0.2.2
|
||||
|
||||
- Bump version
|
||||
|
||||
## 0.2.1
|
||||
|
||||
- Add "All Keywords" view on Categories page
|
||||
- Add dark mode with warm gray palette
|
||||
- Fix orphan categories, persist has_header for imports, add re-initialize
|
||||
- Add Budget and Adjustments pages
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- **Import CSV** — Importez vos relevés bancaires depuis plusieurs sources (Desjardins, etc.)
|
||||
- **Tableau de bord** — Vue d'ensemble avec KPIs, répartition par catégorie et dernières dépenses
|
||||
- **Transactions** — Parcourez, recherchez et filtrez toutes vos transactions
|
||||
- **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 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
|
||||
- **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
|
||||
- **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)
|
||||
2. Téléchargez le fichier `.msi` (installateur Windows)
|
||||
### 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é
|
||||
|
||||
> **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.
|
||||
|
||||
### 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
|
||||
|
||||
### 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 :
|
||||
|
||||
|
|
@ -39,31 +64,43 @@ Documents/
|
|||
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
|
||||
- Configurez le mappage des colonnes (date, description, montant)
|
||||
- Vérifiez l'aperçu puis lancez l'import
|
||||
- Configurez le mappage des colonnes (ou utilisez un modèle d'import sauvegardé)
|
||||
- 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 :
|
||||
- Les KPIs du mois (revenus, dépenses, solde)
|
||||
- La répartition des dépenses par catégorie
|
||||
- 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
|
||||
- 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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
|
|
@ -95,11 +132,11 @@ Les installateurs sont générés dans `src-tauri/target/release/bundle/`.
|
|||
3. Créez et poussez un tag :
|
||||
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
git tag v0.3.7
|
||||
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
|
||||
|
||||
|
|
@ -111,3 +148,4 @@ Le workflow GitHub Actions compile automatiquement l'application et publie les i
|
|||
| [Tailwind CSS v4](https://tailwindcss.com/) | Styles |
|
||||
| [Recharts](https://recharts.org/) | Graphiques |
|
||||
| [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
|
||||
634
package-lock.json
generated
634
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,15 +1,21 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.2.2",
|
||||
"version": "0.6.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
|
|
@ -33,6 +39,7 @@
|
|||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
src-tauri/Cargo.lock
generated
139
src-tauri/Cargo.lock
generated
|
|
@ -8,6 +8,41 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
|
|
@ -62,6 +97,18 @@ dependencies = [
|
|||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
|
|
@ -270,6 +317,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -471,6 +527,16 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
|
|
@ -616,6 +682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -656,6 +723,15 @@ dependencies = [
|
|||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
|
|
@ -1357,6 +1433,16 @@ dependencies = [
|
|||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
|
|
@ -1873,6 +1959,15 @@ dependencies = [
|
|||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
|
|
@ -2580,6 +2675,12 @@ version = "1.21.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
|
|
@ -2682,6 +2783,17 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
|
|
@ -2927,6 +3039,18 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
|
|
@ -3770,9 +3894,12 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
|||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"encoding_rs",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -5097,6 +5224,16 @@ version = "1.12.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "simpl-result"
|
||||
version = "0.2.2"
|
||||
version = "0.6.6"
|
||||
description = "Personal finance management app"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
|
|
@ -24,9 +25,14 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
|||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
encoding_rs = "0.8"
|
||||
walkdir = "2"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
|
|
|
|||
143
src-tauri/src/commands/export_import_commands.rs
Normal file
143
src-tauri/src/commands/export_import_commands.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use argon2::Argon2;
|
||||
use rand::RngCore;
|
||||
use std::fs;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
const MAGIC: &[u8; 4] = b"SREF";
|
||||
const VERSION: u8 = 0x01;
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // 33 bytes
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> {
|
||||
let params = argon2::Params::new(65536, 3, 1, Some(32))
|
||||
.map_err(|e| format!("Argon2 params error: {}", e))?;
|
||||
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| format!("Key derivation error: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_data(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
|
||||
let key = derive_key(password, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| format!("Cipher init error: {}", e))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| format!("Encryption error: {}", e))?;
|
||||
|
||||
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||
output.extend_from_slice(MAGIC);
|
||||
output.push(VERSION);
|
||||
output.extend_from_slice(&salt);
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||
if data.len() < HEADER_LEN + 16 {
|
||||
return Err("File is too small to be a valid encrypted file".to_string());
|
||||
}
|
||||
if &data[0..4] != MAGIC {
|
||||
return Err("Not a valid SREF encrypted file".to_string());
|
||||
}
|
||||
if data[4] != VERSION {
|
||||
return Err(format!("Unsupported SREF version: {}", data[4]));
|
||||
}
|
||||
|
||||
let salt = &data[5..5 + SALT_LEN];
|
||||
let nonce_bytes = &data[5 + SALT_LEN..HEADER_LEN];
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let key = derive_key(password, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|e| format!("Cipher init error: {}", e))?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| "Decryption failed — wrong password or corrupted file".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_save_file(
|
||||
app: tauri::AppHandle,
|
||||
default_name: String,
|
||||
filters: Vec<(String, Vec<String>)>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file().set_file_name(&default_name);
|
||||
|
||||
for (name, extensions) in &filters {
|
||||
let ext_refs: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(name, &ext_refs);
|
||||
}
|
||||
|
||||
let path = dialog.blocking_save_file();
|
||||
Ok(path.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_import_file(
|
||||
app: tauri::AppHandle,
|
||||
filters: Vec<(String, Vec<String>)>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
for (name, extensions) in &filters {
|
||||
let ext_refs: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
|
||||
dialog = dialog.add_filter(name, &ext_refs);
|
||||
}
|
||||
|
||||
let path = dialog.blocking_pick_file();
|
||||
Ok(path.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_export_file(
|
||||
file_path: String,
|
||||
content: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let bytes = match password {
|
||||
Some(ref pw) if !pw.is_empty() => encrypt_data(content.as_bytes(), pw)?,
|
||||
_ => content.into_bytes(),
|
||||
};
|
||||
|
||||
fs::write(&file_path, bytes).map_err(|e| format!("Failed to write file: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_import_file(file_path: String, password: Option<String>) -> Result<String, String> {
|
||||
let bytes = fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
let plaintext = if bytes.len() >= 4 && &bytes[0..4] == MAGIC {
|
||||
let pw = password
|
||||
.filter(|p| !p.is_empty())
|
||||
.ok_or_else(|| "This file is encrypted — a password is required".to_string())?;
|
||||
decrypt_data(&bytes, &pw)?
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
|
||||
String::from_utf8(plaintext).map_err(|e| format!("File content is not valid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_file_encrypted(file_path: String) -> Result<bool, String> {
|
||||
let bytes = fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
Ok(bytes.len() >= 4 && &bytes[0..4] == MAGIC)
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
pub mod fs_commands;
|
||||
pub mod export_import_commands;
|
||||
pub mod profile_commands;
|
||||
|
||||
pub use fs_commands::*;
|
||||
pub use export_import_commands::*;
|
||||
pub use profile_commands::*;
|
||||
|
|
|
|||
219
src-tauri/src/commands/profile_commands.rs
Normal file
219
src-tauri/src/commands/profile_commands.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256, Sha384};
|
||||
use std::fs;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::database;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub pin_hash: Option<String>,
|
||||
pub db_filename: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfilesConfig {
|
||||
pub active_profile_id: String,
|
||||
pub profiles: Vec<Profile>,
|
||||
}
|
||||
|
||||
fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||
Ok(app_dir.join("profiles.json"))
|
||||
}
|
||||
|
||||
fn make_default_config() -> ProfilesConfig {
|
||||
let now = chrono_now();
|
||||
let default_id = "default".to_string();
|
||||
ProfilesConfig {
|
||||
active_profile_id: default_id.clone(),
|
||||
profiles: vec![Profile {
|
||||
id: default_id,
|
||||
name: "Default".to_string(),
|
||||
color: "#4A90A4".to_string(),
|
||||
pin_hash: None,
|
||||
db_filename: "simpl_resultat.db".to_string(),
|
||||
created_at: now,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
// Simple ISO-ish timestamp without pulling in chrono crate
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = dur.as_secs();
|
||||
// Return as unix timestamp string — frontend can format it
|
||||
secs.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_profiles(app: tauri::AppHandle) -> Result<ProfilesConfig, String> {
|
||||
let path = get_profiles_path(&app)?;
|
||||
|
||||
if !path.exists() {
|
||||
let config = make_default_config();
|
||||
let json =
|
||||
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
|
||||
|
||||
// Ensure parent dir exists
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))?;
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(&path).map_err(|e| format!("Cannot read profiles.json: {}", e))?;
|
||||
let config: ProfilesConfig =
|
||||
serde_json::from_str(&content).map_err(|e| format!("Invalid profiles.json: {}", e))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<(), String> {
|
||||
let path = get_profiles_path(&app)?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
|
||||
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> {
|
||||
if db_filename == "simpl_resultat.db" {
|
||||
return Err("Cannot delete the default profile database".to_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() {
|
||||
fs::remove_file(&db_path)
|
||||
.map_err(|e| format!("Cannot delete database file: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
|
||||
Ok(vec![
|
||||
database::CONSOLIDATED_SCHEMA.to_string(),
|
||||
database::SEED_CATEGORIES.to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hash_pin(pin: String) -> Result<String, String> {
|
||||
let mut salt = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
let salt_hex = hex_encode(&salt);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(pin.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let hash_hex = hex_encode(&result);
|
||||
|
||||
// Store as "salt:hash"
|
||||
Ok(format!("{}:{}", salt_hex, hash_hex))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
||||
let parts: Vec<&str> = stored_hash.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid stored hash format".to_string());
|
||||
}
|
||||
let salt_hex = parts[0];
|
||||
let expected_hash = parts[1];
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt_hex.as_bytes());
|
||||
hasher.update(pin.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let computed_hash = hex_encode(&result);
|
||||
|
||||
Ok(computed_hash == expected_hash)
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
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)
|
||||
}
|
||||
184
src-tauri/src/database/consolidated_schema.sql
Normal file
184
src-tauri/src/database/consolidated_schema.sql
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v6)
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- Default preferences
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||
|
|
|
|||
|
|
@ -24,6 +24,61 @@ pub fn run() {
|
|||
sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 4,
|
||||
description: "add is_inputable to categories",
|
||||
sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 5,
|
||||
description: "create import_config_templates table",
|
||||
sql: "CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 6,
|
||||
description: "change imported_files unique constraint from hash to filename",
|
||||
sql: "CREATE TABLE imported_files_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL REFERENCES import_sources(id),
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
INSERT INTO imported_files_new SELECT * FROM imported_files;
|
||||
DROP TABLE imported_files;
|
||||
ALTER TABLE imported_files_new RENAME TO imported_files;",
|
||||
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()
|
||||
|
|
@ -47,6 +102,18 @@ pub fn run() {
|
|||
commands::detect_encoding,
|
||||
commands::get_file_preview,
|
||||
commands::pick_folder,
|
||||
commands::pick_save_file,
|
||||
commands::pick_import_file,
|
||||
commands::write_export_file,
|
||||
commands::read_import_file,
|
||||
commands::is_file_encrypted,
|
||||
commands::load_profiles,
|
||||
commands::save_profiles,
|
||||
commands::delete_profile_db,
|
||||
commands::get_new_profile_init_sql,
|
||||
commands::hash_pin,
|
||||
commands::verify_pin,
|
||||
commands::repair_migrations,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Simpl Résultat",
|
||||
"version": "0.2.2",
|
||||
"productName": "Simpl Resultat",
|
||||
"version": "0.6.6",
|
||||
"identifier": "com.simpl.resultat",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["nsis", "deb", "rpm", "appimage"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
@ -35,12 +35,12 @@
|
|||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE2MTAyRTg2N0Q3OTBBNDAKUldSQUNubDloaTRRRm1hSzdmekpzNThHQ0N0MXpYSklIR012eFlYVHhadUpDbndkSTJUWmNMRk4K",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
|
||||
"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": {
|
||||
"installMode": "passive"
|
||||
"installMode": "basicUi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
src/App.tsx
84
src/App.tsx
|
|
@ -1,4 +1,7 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "./contexts/ProfileContext";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import ImportPage from "./pages/ImportPage";
|
||||
|
|
@ -8,10 +11,87 @@ import AdjustmentsPage from "./pages/AdjustmentsPage";
|
|||
import BudgetPage from "./pages/BudgetPage";
|
||||
import ReportsPage from "./pages/ReportsPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ChangelogPage from "./pages/ChangelogPage";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||
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(() => {
|
||||
if (activeProfile && !isLoading) {
|
||||
setDbReady(false);
|
||||
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);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (startupError) {
|
||||
return <ErrorPage error={startupError} />;
|
||||
}
|
||||
|
||||
if (!activeProfile) {
|
||||
return <ProfileSelectionPage />;
|
||||
}
|
||||
|
||||
if (!dbReady) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BrowserRouter key={refreshKey}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
|
@ -22,6 +102,8 @@ export default function App() {
|
|||
<Route path="/budget" element={<BudgetPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,126 @@
|
|||
import { useState, useRef, useEffect, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BudgetRow } from "../../shared/types";
|
||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"months.jan", "months.feb", "months.mar", "months.apr",
|
||||
"months.may", "months.jun", "months.jul", "months.aug",
|
||||
"months.sep", "months.oct", "months.nov", "months.dec",
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
interface BudgetTableProps {
|
||||
rows: BudgetRow[];
|
||||
onUpdatePlanned: (categoryId: number, amount: number) => void;
|
||||
rows: BudgetYearRow[];
|
||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
|
||||
}
|
||||
|
||||
export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) {
|
||||
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<number | null>(null);
|
||||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||||
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 annualInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCategoryId !== null && inputRef.current) {
|
||||
if (editingCell && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editingCategoryId]);
|
||||
}, [editingCell]);
|
||||
|
||||
const handleStartEdit = (row: BudgetRow) => {
|
||||
setEditingCategoryId(row.category_id);
|
||||
setEditingValue(row.planned === 0 ? "" : String(row.planned));
|
||||
useEffect(() => {
|
||||
if (editingAnnual && annualInputRef.current) {
|
||||
annualInputRef.current.focus();
|
||||
annualInputRef.current.select();
|
||||
}
|
||||
}, [editingAnnual]);
|
||||
|
||||
const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
|
||||
setEditingAnnual(null);
|
||||
setEditingCell({ categoryId, monthIdx });
|
||||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||||
};
|
||||
|
||||
const handleStartEditAnnual = (categoryId: number, currentValue: number) => {
|
||||
setEditingCell(null);
|
||||
setEditingAnnual({ categoryId });
|
||||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingCategoryId === null) return;
|
||||
if (!editingCell) return;
|
||||
const amount = parseFloat(editingValue) || 0;
|
||||
onUpdatePlanned(editingCategoryId, amount);
|
||||
setEditingCategoryId(null);
|
||||
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
|
||||
setEditingCell(null);
|
||||
};
|
||||
|
||||
const handleSaveAnnual = () => {
|
||||
if (!editingAnnual) return;
|
||||
const amount = parseFloat(editingValue) || 0;
|
||||
onSplitEvenly(editingAnnual.categoryId, amount);
|
||||
setEditingAnnual(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingCategoryId(null);
|
||||
setEditingCell(null);
|
||||
setEditingAnnual(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleSave();
|
||||
if (e.key === "Escape") handleCancel();
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (!editingCell) return;
|
||||
const amount = parseFloat(editingValue) || 0;
|
||||
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
|
||||
// Move to next month cell
|
||||
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
|
||||
if (nextMonth >= 0 && nextMonth < 12) {
|
||||
const row = rows.find((r) => r.category_id === editingCell.categoryId && !r.is_parent);
|
||||
if (row) {
|
||||
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
|
||||
}
|
||||
} else {
|
||||
setEditingCell(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnnualKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleSaveAnnual();
|
||||
if (e.key === "Escape") handleCancel();
|
||||
};
|
||||
|
||||
// Sign multiplier: expenses negative, income/transfer positive
|
||||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||
|
||||
// Group rows by type
|
||||
const grouped: Record<string, BudgetRow[]> = {};
|
||||
const grouped: Record<string, BudgetYearRow[]> = {};
|
||||
for (const row of rows) {
|
||||
const key = row.category_type;
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
|
|
@ -57,10 +133,27 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
|
|||
income: "budget.income",
|
||||
transfer: "budget.transfers",
|
||||
};
|
||||
const typeTotalKeys: Record<string, string> = {
|
||||
expense: "budget.totalExpenses",
|
||||
income: "budget.totalIncome",
|
||||
transfer: "budget.totalTransfers",
|
||||
};
|
||||
|
||||
const totalPlanned = rows.reduce((s, r) => s + r.planned, 0);
|
||||
const totalActual = rows.reduce((s, r) => s + Math.abs(r.actual), 0);
|
||||
const totalDifference = totalPlanned - totalActual;
|
||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
||||
const monthTotals: number[] = Array(12).fill(0);
|
||||
let annualTotal = 0;
|
||||
let prevYearTotal = 0;
|
||||
for (const row of rows) {
|
||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||
const sign = signFor(row.category_type);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
monthTotals[m] += row.months[m] * sign;
|
||||
}
|
||||
annualTotal += row.annual * sign;
|
||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||
}
|
||||
|
||||
const totalCols = 15; // category + prev year + annual + 12 months
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
|
|
@ -70,121 +163,225 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
|
|||
);
|
||||
}
|
||||
|
||||
const formatSigned = (value: number) => {
|
||||
if (value === 0) return <span className="text-[var(--muted-foreground)]">—</span>;
|
||||
const color = value > 0 ? "text-[var(--positive)]" : "text-[var(--negative)]";
|
||||
return <span className={color}>{fmt.format(value)}</span>;
|
||||
};
|
||||
|
||||
const renderRow = (row: BudgetYearRow) => {
|
||||
const sign = signFor(row.category_type);
|
||||
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
|
||||
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
||||
|
||||
if (row.is_parent) {
|
||||
// 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 (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<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)}
|
||||
</td>
|
||||
{row.months.map((val, mIdx) => (
|
||||
<td key={mIdx} className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
|
||||
{formatSigned(val * sign)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// Leaf / child row: editable
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||||
>
|
||||
{/* Category name - sticky */}
|
||||
<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">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
<span className="truncate text-xs">{row.category_name}</span>
|
||||
</div>
|
||||
</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 */}
|
||||
<td className="py-2 px-2 text-right">
|
||||
{editingAnnual?.categoryId === row.category_id ? (
|
||||
<input
|
||||
ref={annualInputRef}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onBlur={handleSaveAnnual}
|
||||
onKeyDown={handleAnnualKeyDown}
|
||||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||||
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)}
|
||||
</button>
|
||||
{(() => {
|
||||
const monthSum = row.months.reduce((s, v) => s + v, 0);
|
||||
return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? (
|
||||
<span title={t("budget.annualMismatch")} className="text-[var(--negative)]">
|
||||
<AlertTriangle size={13} />
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 12 month cells */}
|
||||
{row.months.map((val, mIdx) => (
|
||||
<td key={mIdx} className="py-2 px-2 text-right">
|
||||
{editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||
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)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="text-left py-3 px-4 font-medium text-[var(--muted-foreground)]">
|
||||
<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">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<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-30 min-w-[140px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
|
||||
{t("budget.planned")}
|
||||
<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-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
|
||||
{t("budget.difference")}
|
||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||
{t("budget.annual")}
|
||||
</th>
|
||||
{MONTH_KEYS.map((key) => (
|
||||
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
||||
{t(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{typeOrder.map((type) => {
|
||||
const group = grouped[type];
|
||||
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 (
|
||||
<Fragment key={type}>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="py-2 px-4 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
|
||||
colSpan={totalCols}
|
||||
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
|
||||
>
|
||||
{t(typeLabelKeys[type])}
|
||||
</td>
|
||||
</tr>
|
||||
{group.map((row) => (
|
||||
<tr
|
||||
key={row.category_id}
|
||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
<span>{row.category_name}</span>
|
||||
</div>
|
||||
{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>
|
||||
<td className="py-2.5 px-4 text-right">
|
||||
{editingCategoryId === row.category_id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStartEdit(row)}
|
||||
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text"
|
||||
>
|
||||
{row.planned === 0 ? (
|
||||
<span className="text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
fmt.format(row.planned)
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right">
|
||||
{row.actual === 0 ? (
|
||||
<span className="text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
fmt.format(Math.abs(row.actual))
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right">
|
||||
{row.planned === 0 && row.actual === 0 ? (
|
||||
<span className="text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
row.difference >= 0
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--negative)]"
|
||||
}
|
||||
>
|
||||
{fmt.format(row.difference)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))}
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-[var(--muted)] font-semibold">
|
||||
<td className="py-3 px-4">{t("common.total")}</td>
|
||||
<td className="py-3 px-4 text-right">{fmt.format(totalPlanned)}</td>
|
||||
<td className="py-3 px-4 text-right">{fmt.format(totalActual)}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span
|
||||
className={
|
||||
totalDifference >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}
|
||||
>
|
||||
{fmt.format(totalDifference)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Totals row */}
|
||||
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
||||
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</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) => (
|
||||
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
||||
{formatSigned(total)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,23 @@ import type { BudgetTemplate } from "../../shared/types";
|
|||
|
||||
interface TemplateActionsProps {
|
||||
templates: BudgetTemplate[];
|
||||
onApply: (templateId: number) => void;
|
||||
onApply: (templateId: number, month: number) => void;
|
||||
onApplyAllMonths: (templateId: number) => void;
|
||||
onSave: (name: string, description?: string) => void;
|
||||
onDelete: (templateId: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"months.jan", "months.feb", "months.mar", "months.apr",
|
||||
"months.may", "months.jun", "months.jul", "months.aug",
|
||||
"months.sep", "months.oct", "months.nov", "months.dec",
|
||||
] as const;
|
||||
|
||||
export default function TemplateActions({
|
||||
templates,
|
||||
onApply,
|
||||
onApplyAllMonths,
|
||||
onSave,
|
||||
onDelete,
|
||||
disabled,
|
||||
|
|
@ -22,6 +30,7 @@ export default function TemplateActions({
|
|||
const [showApply, setShowApply] = useState(false);
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<number | null>(null);
|
||||
const applyRef = useRef<HTMLDivElement>(null);
|
||||
const saveRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -30,6 +39,7 @@ export default function TemplateActions({
|
|||
const handler = (e: MouseEvent) => {
|
||||
if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) {
|
||||
setShowApply(false);
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) {
|
||||
setShowSave(false);
|
||||
|
|
@ -53,12 +63,30 @@ export default function TemplateActions({
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (templateId: number) => {
|
||||
setSelectedTemplate(templateId);
|
||||
};
|
||||
|
||||
const handleApplyToMonth = (month: number) => {
|
||||
if (selectedTemplate === null) return;
|
||||
onApply(selectedTemplate, month);
|
||||
setShowApply(false);
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
const handleApplyAll = () => {
|
||||
if (selectedTemplate === null) return;
|
||||
onApplyAllMonths(selectedTemplate);
|
||||
setShowApply(false);
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Apply template */}
|
||||
<div ref={applyRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setShowApply(!showApply); setShowSave(false); }}
|
||||
onClick={() => { setShowApply(!showApply); setShowSave(false); setSelectedTemplate(null); }}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -66,27 +94,52 @@ export default function TemplateActions({
|
|||
{t("budget.applyTemplate")}
|
||||
</button>
|
||||
{showApply && (
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-64 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg py-1">
|
||||
{templates.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-[var(--muted-foreground)]">
|
||||
{t("budget.noTemplates")}
|
||||
</p>
|
||||
) : (
|
||||
templates.map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex items-center justify-between px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors"
|
||||
onClick={() => { onApply(tmpl.id); setShowApply(false); }}
|
||||
>
|
||||
<span className="text-sm truncate">{tmpl.name}</span>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, tmpl.id)}
|
||||
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors ml-2"
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg py-1">
|
||||
{selectedTemplate === null ? (
|
||||
// Step 1: Pick a template
|
||||
templates.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-[var(--muted-foreground)]">
|
||||
{t("budget.noTemplates")}
|
||||
</p>
|
||||
) : (
|
||||
templates.map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex items-center justify-between px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors"
|
||||
onClick={() => handleSelectTemplate(tmpl.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<span className="text-sm truncate">{tmpl.name}</span>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, tmpl.id)}
|
||||
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors ml-2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
// Step 2: Pick which month(s) to apply to
|
||||
<div>
|
||||
<p className="px-4 py-2 text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{t("budget.applyToMonth")}
|
||||
</p>
|
||||
<div
|
||||
className="px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors text-sm font-medium text-[var(--primary)]"
|
||||
onClick={handleApplyAll}
|
||||
>
|
||||
{t("budget.allMonths")}
|
||||
</div>
|
||||
))
|
||||
{MONTH_KEYS.map((key, idx) => (
|
||||
<div
|
||||
key={key}
|
||||
className="px-4 py-1.5 hover:bg-[var(--muted)] cursor-pointer transition-colors text-sm"
|
||||
onClick={() => handleApplyToMonth(idx + 1)}
|
||||
>
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
28
src/components/budget/YearNavigator.tsx
Normal file
28
src/components/budget/YearNavigator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface YearNavigatorProps {
|
||||
year: number;
|
||||
onNavigate: (delta: -1 | 1) => void;
|
||||
}
|
||||
|
||||
export default function YearNavigator({ year, onNavigate }: YearNavigatorProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onNavigate(-1)}
|
||||
className="p-1.5 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
aria-label="Previous year"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="min-w-[5rem] text-center font-medium">{year}</span>
|
||||
<button
|
||||
onClick={() => onNavigate(1)}
|
||||
className="p-1.5 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
aria-label="Next year"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
getAllKeywordsWithCategory,
|
||||
type KeywordWithCategory,
|
||||
|
|
@ -15,10 +15,12 @@ function normalize(str: string): string {
|
|||
|
||||
interface AllKeywordsPanelProps {
|
||||
onSelectCategory: (id: number) => void;
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function AllKeywordsPanel({
|
||||
onSelectCategory,
|
||||
onRemove,
|
||||
}: AllKeywordsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [keywords, setKeywords] = useState<KeywordWithCategory[]>([]);
|
||||
|
|
@ -89,6 +91,7 @@ export default function AllKeywordsPanel({
|
|||
</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 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -111,6 +114,17 @@ export default function AllKeywordsPanel({
|
|||
{k.category_name}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -133,10 +133,6 @@ export default function CategoryDetailPanel({
|
|||
<span className="text-[var(--muted-foreground)]">{t("categories.type")}</span>
|
||||
<p className="font-medium capitalize">{t(`categories.${selectedCategory.type}`)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">{t("categories.sortOrder")}</span>
|
||||
<p className="font-medium">{selectedCategory.sort_order}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">{t("categories.parent")}</span>
|
||||
<p className="font-medium">
|
||||
|
|
|
|||
|
|
@ -41,9 +41,36 @@ export default function CategoryForm({
|
|||
setForm(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
const parentOptions = categories.filter(
|
||||
(c) => c.parent_id === null
|
||||
);
|
||||
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
|
||||
// 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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -113,20 +140,22 @@ export default function CategoryForm({
|
|||
<option value="">{t("categories.noParent")}</option>
|
||||
{parentOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("categories.sortOrder")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={form.sort_order}
|
||||
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
|
||||
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
type="checkbox"
|
||||
id="is_inputable"
|
||||
checked={form.is_inputable}
|
||||
onChange={(e) => setForm({ ...form, is_inputable: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-[var(--border)] accent-[var(--primary)]"
|
||||
/>
|
||||
<label htmlFor="is_inputable" className="text-sm font-medium">{t("categories.isInputable")}</label>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("categories.isInputableHint")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,59 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, GripVertical } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { CategoryTreeNode } from "../../shared/types";
|
||||
|
||||
interface FlatItem {
|
||||
id: number;
|
||||
node: CategoryTreeNode;
|
||||
depth: number;
|
||||
parentId: number | null;
|
||||
isExpanded: boolean;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tree: CategoryTreeNode[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => 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[] {
|
||||
const items: FlatItem[] = [];
|
||||
function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) {
|
||||
for (const node of nodes) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedSet.has(node.id);
|
||||
items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren });
|
||||
if (isExpanded && hasChildren) {
|
||||
recurse(node.children, depth + 1, node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
recurse(tree, 0, null);
|
||||
return items;
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
|
|
@ -23,7 +70,7 @@ function TypeBadge({ type }: { type: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
function TreeRowContent({
|
||||
node,
|
||||
depth,
|
||||
selectedId,
|
||||
|
|
@ -31,98 +78,251 @@ function TreeRow({
|
|||
expanded,
|
||||
onToggle,
|
||||
hasChildren,
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: {
|
||||
node: CategoryTreeNode;
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
onSelect?: (id: number) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onToggle?: () => void;
|
||||
hasChildren: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isSelected = node.id === selectedId;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left rounded-lg transition-colors
|
||||
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}`}
|
||||
style={{ paddingLeft: `${depth * 20 + 12}px` }}
|
||||
<div
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-2 text-sm rounded-lg transition-colors
|
||||
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}
|
||||
${isDragging ? "opacity-40" : ""}`}
|
||||
style={{ paddingLeft: `${depth * 20 + 4}px` }}
|
||||
>
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
className="w-5 h-5 flex items-center justify-center cursor-grab text-[var(--muted-foreground)] hover:text-[var(--foreground)] flex-shrink-0"
|
||||
title={t("categories.dragToReorder")}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
onToggle?.();
|
||||
}}
|
||||
className="w-4 h-4 flex items-center justify-center cursor-pointer text-[var(--muted-foreground)]"
|
||||
className="w-4 h-4 flex items-center justify-center cursor-pointer text-[var(--muted-foreground)] flex-shrink-0"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: node.color ?? "#9ca3af" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<TypeBadge type={node.type} />
|
||||
{node.keyword_count > 0 && (
|
||||
<span className="text-[11px] text-[var(--muted-foreground)]">
|
||||
{node.keyword_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelect?.(node.id)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: node.color ?? "#9ca3af" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<TypeBadge type={node.type} />
|
||||
{node.keyword_count > 0 && (
|
||||
<span className="text-[11px] text-[var(--muted-foreground)]">
|
||||
{node.keyword_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoryTree({ tree, selectedId, onSelect }: Props) {
|
||||
function SortableTreeRow({
|
||||
item,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onToggle,
|
||||
isDragActive,
|
||||
}: {
|
||||
item: FlatItem;
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
onToggle: (id: number) => void;
|
||||
isDragActive: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
position: "relative" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<TreeRowContent
|
||||
node={item.node}
|
||||
depth={item.depth}
|
||||
selectedId={isDragActive ? null : selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={item.isExpanded}
|
||||
onToggle={() => onToggle(item.id)}
|
||||
hasChildren={item.hasChildren}
|
||||
dragHandleProps={listeners}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
|
||||
const [expanded, setExpanded] = useState<Set<number>>(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const node of tree) {
|
||||
if (node.children.length > 0) ids.add(node.id);
|
||||
function collectExpandable(nodes: CategoryTreeNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length > 0) {
|
||||
ids.add(node.id);
|
||||
collectExpandable(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectExpandable(tree);
|
||||
return ids;
|
||||
});
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
// Update expanded set when tree changes (new parents appear)
|
||||
const flatItems = useMemo(() => flattenTree(tree, expanded), [tree, expanded]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null),
|
||||
[activeId, flatItems]
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const toggle = useCallback((id: number) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as number);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeIdx = flatItems.findIndex((i) => i.id === active.id);
|
||||
const overIdx = flatItems.findIndex((i) => i.id === over.id);
|
||||
if (activeIdx === -1 || overIdx === -1) return;
|
||||
|
||||
const activeItem = flatItems[activeIdx];
|
||||
const overItem = flatItems[overIdx];
|
||||
|
||||
// Compute the depth of the active item's subtree
|
||||
const activeSubtreeDepth = getSubtreeDepth(activeItem.node);
|
||||
|
||||
// Determine the new parent and index
|
||||
let newParentId: number | null;
|
||||
let newIndex: number;
|
||||
|
||||
if (overItem.depth === 0) {
|
||||
// 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) {
|
||||
newIndex = overRootIdx;
|
||||
} else {
|
||||
newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx;
|
||||
}
|
||||
} else {
|
||||
// Dropping onto/near a non-root item — adopt same parent
|
||||
newParentId = overItem.parentId;
|
||||
const siblings = flatItems.filter(
|
||||
(i) => i.depth === overItem.depth && i.parentId === overItem.parentId
|
||||
);
|
||||
const overSiblingIdx = siblings.findIndex((i) => i.id === over.id);
|
||||
newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx;
|
||||
if (activeItem.parentId === newParentId) {
|
||||
const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id);
|
||||
if (activeSiblingIdx < overSiblingIdx) {
|
||||
newIndex = overSiblingIdx;
|
||||
} else {
|
||||
newIndex = overSiblingIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 3-level constraint: targetDepth + subtreeDepth must be <= 2 (max index)
|
||||
const targetDepth = newParentId === null ? 0 : overItem.depth;
|
||||
if (targetDepth + activeSubtreeDepth > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveCategory(active.id as number, newParentId, newIndex);
|
||||
},
|
||||
[flatItems, onMoveCategory]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tree.map((parent) => (
|
||||
<div key={parent.id}>
|
||||
<TreeRow
|
||||
node={parent}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={expanded.has(parent.id)}
|
||||
onToggle={() => toggle(parent.id)}
|
||||
hasChildren={parent.children.length > 0}
|
||||
/>
|
||||
{expanded.has(parent.id) &&
|
||||
parent.children.map((child) => (
|
||||
<TreeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
hasChildren={false}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={flatItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{flatItems.map((item) => (
|
||||
<SortableTreeRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onToggle={toggle}
|
||||
isDragActive={activeId !== null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeItem && (
|
||||
<div className="bg-[var(--card)] rounded-lg shadow-lg border border-[var(--primary)] opacity-90">
|
||||
<TreeRowContent
|
||||
node={activeItem.node}
|
||||
depth={0}
|
||||
selectedId={null}
|
||||
expanded={false}
|
||||
hasChildren={activeItem.hasChildren}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,156 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||
|
||||
interface CategoryPieChartProps {
|
||||
data: CategoryBreakdownItem[];
|
||||
hiddenCategories: Set<string>;
|
||||
onToggleHidden: (categoryName: string) => void;
|
||||
onShowAll: () => void;
|
||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||
}
|
||||
|
||||
export default function CategoryPieChart({ data }: CategoryPieChartProps) {
|
||||
export default function CategoryPieChart({
|
||||
data,
|
||||
hiddenCategories,
|
||||
onToggleHidden,
|
||||
onShowAll,
|
||||
onViewDetails,
|
||||
}: CategoryPieChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||
const [isChartHovered, setIsChartHovered] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||
|
||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!hoveredRef.current) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, item: hoveredRef.current });
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 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-8">{t("dashboard.noData")}</p>
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.total, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="total"
|
||||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||
{hiddenCategories.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||
{Array.from(hiddenCategories).map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => onToggleHidden(name)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
<Eye size={12} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onShowAll}
|
||||
className="text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<Cell key={index} fill={item.category_color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5 text-sm">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
|
||||
style={{ backgroundColor: item.category_color }}
|
||||
{t("charts.showAll")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={() => setIsChartHovered(true)}
|
||||
onMouseLeave={() => setIsChartHovered(false)}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-pie"
|
||||
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
|
||||
/>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Pie
|
||||
data={visibleData}
|
||||
dataKey="total"
|
||||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={35}
|
||||
outerRadius={75}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{visibleData.map((item, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={getPatternFill("cat-pie", index, item.category_color)}
|
||||
onMouseEnter={() => { hoveredRef.current = item; }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; }}
|
||||
cursor="context-menu"
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(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={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{data.map((item, index) => {
|
||||
const isHidden = hiddenCategories.has(item.category_name);
|
||||
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||
}}
|
||||
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
|
||||
title={isHidden ? t("charts.clickToShow") : undefined}
|
||||
>
|
||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ChartContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
categoryName={contextMenu.item.category_name}
|
||||
onHide={() => onToggleHidden(contextMenu.item.category_name)}
|
||||
onViewDetails={() => onViewDetails(contextMenu.item)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,66 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Calendar } from "lucide-react";
|
||||
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 {
|
||||
value: DashboardPeriod;
|
||||
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 [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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 items-center relative">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onChange(p)}
|
||||
onClick={() => {
|
||||
onChange(p);
|
||||
setShowCustom(false);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === value
|
||||
? "bg-[var(--primary)] text-white"
|
||||
|
|
@ -26,6 +70,60 @@ export default function PeriodSelector({ value, onChange }: PeriodSelectorProps)
|
|||
{t(`dashboard.period.${p}`)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
src/components/import/FilePreviewModal.tsx
Normal file
61
src/components/import/FilePreviewModal.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import FilePreviewTable from "./FilePreviewTable";
|
||||
import type { ParsedRow } from "../../shared/types";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
rows: ParsedRow[];
|
||||
totalCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FilePreviewModal({
|
||||
rows,
|
||||
totalCount,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold">{t("import.preview.title")}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<FilePreviewTable rows={rows} />
|
||||
{totalCount > rows.length && (
|
||||
<p className="text-sm text-[var(--muted-foreground)] text-center mt-4">
|
||||
{t("import.preview.moreRows", {
|
||||
count: totalCount - rows.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Inbox } from "lucide-react";
|
||||
import { Trash2, Inbox, AlertTriangle } from "lucide-react";
|
||||
import { useImportHistory } from "../../hooks/useImportHistory";
|
||||
|
||||
interface ImportHistoryPanelProps {
|
||||
|
|
@ -11,6 +12,8 @@ export default function ImportHistoryPanel({
|
|||
}: ImportHistoryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ id: number; filename: string; rowCount: number } | null>(null);
|
||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
|
|
@ -20,7 +23,7 @@ export default function ImportHistoryPanel({
|
|||
</h2>
|
||||
{state.files.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
onClick={() => setConfirmDeleteAll(true)}
|
||||
disabled={state.isDeleting}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -99,7 +102,7 @@ export default function ImportHistoryPanel({
|
|||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleDelete(file.id, file.row_count)}
|
||||
onClick={() => setConfirmDelete({ id: file.id, filename: file.filename, rowCount: file.row_count })}
|
||||
disabled={state.isDeleting}
|
||||
className="p-1 rounded hover:bg-[var(--muted)] text-[var(--negative)] disabled:opacity-50"
|
||||
title={t("common.delete")}
|
||||
|
|
@ -113,6 +116,77 @@ export default function ImportHistoryPanel({
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm delete single import */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-[var(--negative)]/10">
|
||||
<AlertTriangle size={20} className="text-[var(--negative)]" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("common.delete")}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-1">
|
||||
<span className="font-medium text-[var(--foreground)]">{confirmDelete.filename}</span>
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-6">
|
||||
{t("import.history.deleteConfirm", { count: confirmDelete.rowCount })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDelete(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm delete all imports */}
|
||||
{confirmDeleteAll && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-[var(--negative)]/10">
|
||||
<AlertTriangle size={20} className="text-[var(--negative)]" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("import.history.deleteAll")}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-6">
|
||||
{t("import.history.deleteAllConfirm")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAll(false)}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDeleteAll();
|
||||
setConfirmDeleteAll(false);
|
||||
}}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { Wand2, Check, Save, X } from "lucide-react";
|
||||
import type {
|
||||
ScannedSource,
|
||||
ScannedFile,
|
||||
SourceConfig,
|
||||
AmountMode,
|
||||
ColumnMapping,
|
||||
ImportConfigTemplate,
|
||||
} from "../../shared/types";
|
||||
import ColumnMappingEditor from "./ColumnMappingEditor";
|
||||
|
||||
|
|
@ -13,11 +15,18 @@ interface SourceConfigPanelProps {
|
|||
source: ScannedSource;
|
||||
config: SourceConfig;
|
||||
selectedFiles: ScannedFile[];
|
||||
importedFileNames?: Set<string>;
|
||||
headers: string[];
|
||||
configTemplates: ImportConfigTemplate[];
|
||||
onConfigChange: (config: SourceConfig) => void;
|
||||
onFileToggle: (file: ScannedFile) => void;
|
||||
onSelectAllFiles: () => void;
|
||||
onAutoDetect: () => void;
|
||||
onSaveAsTemplate: (name: string) => void;
|
||||
onApplyTemplate: (id: number) => void;
|
||||
onUpdateTemplate: () => void;
|
||||
onDeleteTemplate: (id: number) => void;
|
||||
selectedTemplateId: number | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -25,14 +34,23 @@ export default function SourceConfigPanel({
|
|||
source,
|
||||
config,
|
||||
selectedFiles,
|
||||
importedFileNames,
|
||||
headers,
|
||||
configTemplates,
|
||||
onConfigChange,
|
||||
onFileToggle,
|
||||
onSelectAllFiles,
|
||||
onAutoDetect,
|
||||
onSaveAsTemplate,
|
||||
onApplyTemplate,
|
||||
onUpdateTemplate,
|
||||
onDeleteTemplate,
|
||||
selectedTemplateId,
|
||||
isLoading,
|
||||
}: SourceConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
|
||||
const selectClass =
|
||||
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
|
||||
|
|
@ -58,6 +76,104 @@ export default function SourceConfigPanel({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Template row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
|
||||
<label className="text-sm text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{t("import.config.loadTemplate")}
|
||||
</label>
|
||||
<select
|
||||
value={selectedTemplateId ?? ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onApplyTemplate(Number(e.target.value));
|
||||
}}
|
||||
className={selectClass + " flex-1"}
|
||||
>
|
||||
<option value="">
|
||||
{configTemplates.length === 0
|
||||
? t("import.config.noTemplates")
|
||||
: `— ${t("import.config.loadTemplate")} —`}
|
||||
</option>
|
||||
{configTemplates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTemplateId && (
|
||||
<>
|
||||
<button
|
||||
onClick={onUpdateTemplate}
|
||||
title={t("import.config.updateTemplate")}
|
||||
className="p-1.5 rounded-lg text-[var(--primary)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteTemplate(selectedTemplateId)}
|
||||
title={t("import.config.deleteTemplate")}
|
||||
className="p-1.5 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveTemplate ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder={t("import.config.templateName")}
|
||||
className={inputClass + " w-48"}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && templateName.trim()) {
|
||||
onSaveAsTemplate(templateName.trim());
|
||||
setTemplateName("");
|
||||
setShowSaveTemplate(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowSaveTemplate(false);
|
||||
setTemplateName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (templateName.trim()) {
|
||||
onSaveAsTemplate(templateName.trim());
|
||||
setTemplateName("");
|
||||
setShowSaveTemplate(false);
|
||||
}
|
||||
}}
|
||||
disabled={!templateName.trim()}
|
||||
className="p-1.5 rounded-lg bg-[var(--positive)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSaveTemplate(false);
|
||||
setTemplateName("");
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("import.config.saveAsTemplate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source name */}
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
|
||||
|
|
@ -222,10 +338,13 @@ export default function SourceConfigPanel({
|
|||
const isSelected = selectedFiles.some(
|
||||
(f) => f.file_path === file.file_path
|
||||
);
|
||||
const isImported = importedFileNames?.has(file.filename) ?? false;
|
||||
return (
|
||||
<label
|
||||
key={file.file_path}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm"
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm ${
|
||||
isImported ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -234,6 +353,12 @@ export default function SourceConfigPanel({
|
|||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex-1">{file.filename}</span>
|
||||
{isImported && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
||||
<Check size={12} />
|
||||
{t("import.config.alreadyImported")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{(file.size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import SourceCard from "./SourceCard";
|
|||
interface SourceListProps {
|
||||
sources: ScannedSource[];
|
||||
configuredSourceNames: Set<string>;
|
||||
importedFileHashes: Map<string, Set<string>>;
|
||||
importedFileNames: Map<string, Set<string>>;
|
||||
onSelectSource: (source: ScannedSource) => void;
|
||||
}
|
||||
|
||||
export default function SourceList({
|
||||
sources,
|
||||
configuredSourceNames,
|
||||
importedFileHashes,
|
||||
importedFileNames,
|
||||
onSelectSource,
|
||||
}: SourceListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -41,7 +41,7 @@ export default function SourceList({
|
|||
{sources.map((source) => {
|
||||
const isConfigured = configuredSourceNames.has(source.folder_name);
|
||||
// Count files not yet imported for this source
|
||||
const sourceHashes = importedFileHashes.get(source.folder_name);
|
||||
const sourceHashes = importedFileNames.get(source.folder_name);
|
||||
const newFileCount = sourceHashes
|
||||
? source.files.filter(
|
||||
(f) => !sourceHashes.has(f.filename)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import ProfileSwitcher from "../profile/ProfileSwitcher";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||
LayoutDashboard,
|
||||
|
|
@ -42,6 +43,8 @@ export default function Sidebar() {
|
|||
<h1 className="text-lg font-bold tracking-tight">{APP_NAME}</h1>
|
||||
</div>
|
||||
|
||||
<ProfileSwitcher />
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1 px-3">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = iconMap[item.icon];
|
||||
|
|
|
|||
121
src/components/profile/PinDialog.tsx
Normal file
121
src/components/profile/PinDialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { verifyPin } from "../../services/profileService";
|
||||
|
||||
interface Props {
|
||||
profileName: string;
|
||||
storedHash: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PinDialog({ profileName, storedHash, onSuccess, onCancel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [digits, setDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
||||
const [error, setError] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
const handleInput = async (index: number, value: string) => {
|
||||
if (!/^\d?$/.test(value)) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = value;
|
||||
setDigits(newDigits);
|
||||
setError(false);
|
||||
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Check PIN when we have at least 4 digits filled
|
||||
const pin = newDigits.join("");
|
||||
if (pin.length >= 4 && !newDigits.slice(0, 4).includes("")) {
|
||||
// If all filled digits are present and we're at the last one typed
|
||||
const filledCount = newDigits.filter((d) => d !== "").length;
|
||||
if (value && filledCount === index + 1) {
|
||||
setChecking(true);
|
||||
try {
|
||||
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
||||
if (valid) {
|
||||
onSuccess();
|
||||
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
|
||||
setError(true);
|
||||
setDigits(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
const pin = digits.join("");
|
||||
if (pin.length >= 4) {
|
||||
setChecking(true);
|
||||
verifyPin(pin, storedHash).then((valid) => {
|
||||
setChecking(false);
|
||||
if (valid) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(true);
|
||||
setDigits(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-xs border border-[var(--border)] p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-[var(--foreground)]">{profileName}</h3>
|
||||
<button onClick={onCancel} className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("profile.enterPin")}</p>
|
||||
|
||||
<div className="flex gap-2 justify-center mb-4">
|
||||
{digits.map((digit, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => { inputRefs.current[i] = el; }}
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleInput(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
disabled={checking}
|
||||
className={`w-10 h-12 text-center text-lg font-bold rounded-lg border-2 bg-[var(--background)] text-[var(--foreground)] ${
|
||||
error ? "border-[var(--negative)]" : "border-[var(--border)] focus:border-[var(--primary)]"
|
||||
} outline-none transition-colors`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-[var(--negative)] text-center">{t("profile.wrongPin")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
src/components/profile/ProfileFormModal.tsx
Normal file
223
src/components/profile/ProfileFormModal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react";
|
||||
import { useProfile } from "../../contexts/ProfileContext";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
|
||||
"#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
editProfileId?: string;
|
||||
}
|
||||
|
||||
export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
|
||||
|
||||
const editProfile = editProfileId
|
||||
? profiles.find((p) => p.id === editProfileId)
|
||||
: null;
|
||||
|
||||
const [mode, setMode] = useState<"list" | "create" | "edit">(
|
||||
editProfileId ? "edit" : "list"
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
|
||||
const [name, setName] = useState(editProfile?.name ?? "");
|
||||
const [color, setColor] = useState(editProfile?.color ?? PRESET_COLORS[0]);
|
||||
const [pin, setPin_] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleCreate = () => {
|
||||
setMode("create");
|
||||
setName("");
|
||||
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
|
||||
setPin_("");
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const p = profiles.find((pr) => pr.id === id);
|
||||
if (!p) return;
|
||||
setMode("edit");
|
||||
setSelectedId(id);
|
||||
setName(p.name);
|
||||
setColor(p.color);
|
||||
setPin_("");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await createProfile(name.trim(), color, pin.length >= 4 ? pin : undefined);
|
||||
} else if (mode === "edit" && selectedId) {
|
||||
await updateProfile(selectedId, { name: name.trim(), color });
|
||||
}
|
||||
setMode("list");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile || profile.db_filename === "simpl_resultat.db") return;
|
||||
if (!confirm(t("profile.deleteConfirm"))) return;
|
||||
await deleteProfile(id);
|
||||
};
|
||||
|
||||
const handleTogglePin = async (id: string) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
|
||||
if (profile.pin_hash) {
|
||||
await setPin(id, null);
|
||||
} else {
|
||||
const newPin = prompt(t("profile.setPin"));
|
||||
if (newPin && newPin.length >= 4) {
|
||||
await setPin(id, newPin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||
<h2 className="font-semibold text-[var(--foreground)]">
|
||||
{mode === "create"
|
||||
? t("profile.create")
|
||||
: mode === "edit"
|
||||
? t("profile.edit")
|
||||
: t("profile.manageProfiles")}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{mode === "list" ? (
|
||||
<div className="space-y-2">
|
||||
{profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--muted)]/30 border border-[var(--border)]"
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: profile.color }}
|
||||
>
|
||||
{profile.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 font-medium text-sm text-[var(--foreground)]">
|
||||
{profile.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleTogglePin(profile.id)}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
title={profile.pin_hash ? t("profile.removePin") : t("profile.setPin")}
|
||||
>
|
||||
{profile.pin_hash ? <Lock size={14} /> : <LockOpen size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(profile.id)}
|
||||
className="text-xs px-2 py-1 rounded bg-[var(--primary)] text-white hover:opacity-90"
|
||||
>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
{profile.db_filename !== "simpl_resultat.db" && (
|
||||
<button
|
||||
onClick={() => handleDelete(profile.id)}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--negative)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t("profile.create")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||
{t("profile.name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("profile.namePlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
{t("profile.color")}
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||
color === c ? "border-[var(--foreground)] scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "create" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||
{t("profile.pin")} <span className="text-[var(--muted-foreground)] font-normal">({t("common.cancel").toLowerCase()})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pin}
|
||||
onChange={(e) => setPin_(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="4-6 digits"
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-sm"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setMode("list")}
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/components/profile/ProfileSwitcher.tsx
Normal file
109
src/components/profile/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Lock, Settings } from "lucide-react";
|
||||
import { useProfile } from "../../contexts/ProfileContext";
|
||||
import PinDialog from "./PinDialog";
|
||||
import ProfileFormModal from "./ProfileFormModal";
|
||||
import type { Profile } from "../../services/profileService";
|
||||
|
||||
export default function ProfileSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, activeProfile, switchProfile } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (profile: Profile) => {
|
||||
setOpen(false);
|
||||
if (profile.id === activeProfile?.id) return;
|
||||
|
||||
if (profile.pin_hash) {
|
||||
setPinProfile(profile);
|
||||
} else {
|
||||
switchProfile(profile.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = () => {
|
||||
if (pinProfile) {
|
||||
switchProfile(pinProfile.id);
|
||||
setPinProfile(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className="relative px-3 pb-2">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: activeProfile?.color }}
|
||||
/>
|
||||
<span className="truncate flex-1 text-left">{activeProfile?.name}</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => handleSelect(profile)}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
|
||||
profile.id === activeProfile?.id
|
||||
? "bg-[var(--sidebar-active)] text-white"
|
||||
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: profile.color }}
|
||||
/>
|
||||
<span className="truncate flex-1 text-left">{profile.name}</span>
|
||||
{profile.pin_hash && <Lock size={12} className="opacity-50" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setShowManage(true);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm border-t border-white/10 hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>{t("profile.manageProfiles")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pinProfile && (
|
||||
<PinDialog
|
||||
profileName={pinProfile.name}
|
||||
storedHash={pinProfile.pin_hash!}
|
||||
onSuccess={handlePinSuccess}
|
||||
onCancel={() => setPinProfile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showManage && (
|
||||
<ProfileFormModal onClose={() => setShowManage(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
src/components/reports/BudgetVsActualTable.tsx
Normal file
273
src/components/reports/BudgetVsActualTable.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
|
||||
const pctFormatter = (value: number | null) =>
|
||||
value == null ? "—" : `${(value * 100).toFixed(1)}%`;
|
||||
|
||||
function variationColor(value: number): string {
|
||||
if (value > 0) return "text-[var(--positive)]";
|
||||
if (value < 0) return "text-[var(--negative)]";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface BudgetVsActualTableProps {
|
||||
data: BudgetVsActualRow[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||
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) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.bva.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group rows by type for section headers
|
||||
type SectionType = "expense" | "income" | "transfer";
|
||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||
const typeLabels: Record<SectionType, string> = {
|
||||
expense: t("budget.expenses"),
|
||||
income: t("budget.income"),
|
||||
transfer: t("budget.transfers"),
|
||||
};
|
||||
const typeTotalKeys: Record<SectionType, string> = {
|
||||
expense: "budget.totalExpenses",
|
||||
income: "budget.totalIncome",
|
||||
transfer: "budget.totalTransfers",
|
||||
};
|
||||
|
||||
let currentType: SectionType | null = null;
|
||||
for (const row of data) {
|
||||
if (row.category_type !== currentType) {
|
||||
currentType = row.category_type;
|
||||
sections.push({ type: currentType, label: typeLabels[currentType], rows: [] });
|
||||
}
|
||||
sections[sections.length - 1].rows.push(row);
|
||||
}
|
||||
|
||||
// Grand totals (leaf rows only)
|
||||
const leaves = data.filter((r) => !r.is_parent);
|
||||
const totals = leaves.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 totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<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 sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<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")}
|
||||
</th>
|
||||
<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")}
|
||||
</th>
|
||||
</tr>
|
||||
<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)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
<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")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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}>
|
||||
<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 sticky left-0 bg-[var(--muted)]">
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||
const isParent = 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 (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
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={`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="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 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{cadFormatter(row.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||
{pctFormatter(row.monthVariationPct)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{cadFormatter(row.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||
{pctFormatter(row.ytdVariationPct)}
|
||||
</td>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{/* Grand totals */}
|
||||
<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-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-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{cadFormatter(totals.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{pctFormatter(totalMonthPct)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{cadFormatter(totals.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{pctFormatter(totalYtdPct)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
|
|
@ -7,18 +8,45 @@ import {
|
|||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
interface CategoryBarChartProps {
|
||||
data: CategoryBreakdownItem[];
|
||||
hiddenCategories: Set<string>;
|
||||
onToggleHidden: (categoryName: string) => void;
|
||||
onShowAll: () => void;
|
||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
||||
export default function CategoryBarChart({
|
||||
data,
|
||||
hiddenCategories,
|
||||
onToggleHidden,
|
||||
onShowAll,
|
||||
onViewDetails,
|
||||
showAmounts,
|
||||
}: CategoryBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
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 visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!hoveredRef.current) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, item: hoveredRef.current });
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
|
|
@ -30,39 +58,96 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
|||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => cadFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category_name"
|
||||
width={120}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<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)" }}
|
||||
/>
|
||||
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
||||
{data.map((item, index) => (
|
||||
<Cell key={index} fill={item.category_color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{hiddenCategories.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||
{Array.from(hiddenCategories).map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => onToggleHidden(name)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
<Eye size={12} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onShowAll}
|
||||
className="text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t("charts.showAll")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ResponsiveContainer width="100%" height={Math.max(400, visibleData.length * 40)}>
|
||||
<BarChart data={visibleData} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-bar"
|
||||
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => cadFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category_name"
|
||||
width={120}
|
||||
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
||||
{visibleData.map((item, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={getPatternFill("cat-bar", index, item.category_color)}
|
||||
fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3}
|
||||
onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }}
|
||||
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>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ChartContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
categoryName={contextMenu.item.category_name}
|
||||
onHide={() => onToggleHidden(contextMenu.item.category_name)}
|
||||
onViewDetails={() => onViewDetails(contextMenu.item)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
|
|
@ -8,8 +9,12 @@ import {
|
|||
ResponsiveContainer,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { CategoryOverTimeData } from "../../shared/types";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
|
@ -22,10 +27,38 @@ function formatMonth(month: string): string {
|
|||
|
||||
interface CategoryOverTimeChartProps {
|
||||
data: CategoryOverTimeData;
|
||||
hiddenCategories: Set<string>;
|
||||
onToggleHidden: (categoryName: string) => void;
|
||||
onShowAll: () => void;
|
||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartProps) {
|
||||
export default function CategoryOverTimeChart({
|
||||
data,
|
||||
hiddenCategories,
|
||||
onToggleHidden,
|
||||
onShowAll,
|
||||
onViewDetails,
|
||||
showAmounts,
|
||||
}: CategoryOverTimeChartProps) {
|
||||
const { t } = useTranslation();
|
||||
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 visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
||||
const categoryEntries = visibleCategories.map((name, index) => ({
|
||||
name,
|
||||
color: data.colors[name],
|
||||
index,
|
||||
}));
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!hoveredRef.current) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, name: hoveredRef.current });
|
||||
}, []);
|
||||
|
||||
if (data.data.length === 0) {
|
||||
return (
|
||||
|
|
@ -37,44 +70,119 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
|
|||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonth}
|
||||
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)}
|
||||
labelFormatter={(label) => formatMonth(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Legend />
|
||||
{data.categories.map((name) => (
|
||||
<Bar
|
||||
{hiddenCategories.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||
{Array.from(hiddenCategories).map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
dataKey={name}
|
||||
stackId="stack"
|
||||
fill={data.colors[name]}
|
||||
/>
|
||||
onClick={() => onToggleHidden(name)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
<Eye size={12} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<button
|
||||
onClick={onShowAll}
|
||||
className="text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t("charts.showAll")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-time"
|
||||
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
|
||||
/>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonth}
|
||||
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: unknown, name: unknown) => {
|
||||
if (hoveredCategory && name !== hoveredCategory) return [null, null];
|
||||
return [cadFormatter(Number(value) || 0), String(name)];
|
||||
}}
|
||||
labelFormatter={(label) => formatMonth(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
labelStyle={{ 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>}
|
||||
/>
|
||||
{categoryEntries.map((c) => (
|
||||
<Bar
|
||||
key={c.name}
|
||||
dataKey={c.name}
|
||||
stackId="stack"
|
||||
fill={getPatternFill("cat-time", c.index, c.color)}
|
||||
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
||||
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
||||
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
||||
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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ChartContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
categoryName={contextMenu.name}
|
||||
onHide={() => onToggleHidden(contextMenu.name)}
|
||||
onViewDetails={() => {
|
||||
const color = data.colors[contextMenu.name] || "#9ca3af";
|
||||
const categoryId = data.categoryIds[contextMenu.name] ?? null;
|
||||
onViewDetails({
|
||||
category_id: categoryId,
|
||||
category_name: contextMenu.name,
|
||||
category_color: color,
|
||||
total: 0,
|
||||
});
|
||||
}}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { MonthlyTrendItem } from "../../shared/types";
|
||||
|
||||
|
|
@ -21,9 +22,10 @@ function formatMonth(month: string): string {
|
|||
|
||||
interface MonthlyTrendsChartProps {
|
||||
data: MonthlyTrendItem[];
|
||||
showAmounts?: boolean;
|
||||
}
|
||||
|
||||
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||
export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
|
|
@ -80,7 +82,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
|||
stroke="var(--positive)"
|
||||
fill="url(#gradientIncome)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
>
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey="income"
|
||||
position="top"
|
||||
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||
style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Area>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
|
|
@ -88,7 +99,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
|||
stroke="var(--negative)"
|
||||
fill="url(#gradientExpenses)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
>
|
||||
{showAmounts && (
|
||||
<LabelList
|
||||
dataKey="expenses"
|
||||
position="bottom"
|
||||
formatter={(v: unknown) => cadFormatter(Number(v))}
|
||||
style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Area>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
330
src/components/settings/DataManagementCard.tsx
Normal file
330
src/components/settings/DataManagementCard.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
Upload,
|
||||
Lock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useDataExport } from "../../hooks/useDataExport";
|
||||
import { useDataImport } from "../../hooks/useDataImport";
|
||||
import type { ExportMode, ExportFormat } from "../../services/dataExportService";
|
||||
import ImportConfirmModal from "./ImportConfirmModal";
|
||||
|
||||
export default function DataManagementCard() {
|
||||
const { t } = useTranslation();
|
||||
const exportHook = useDataExport();
|
||||
const importHook = useDataImport();
|
||||
|
||||
// Export form state
|
||||
const [exportMode, setExportMode] = useState<ExportMode>(
|
||||
"transactions_with_categories"
|
||||
);
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("json");
|
||||
const [encryptExport, setEncryptExport] = useState(false);
|
||||
const [exportPassword, setExportPassword] = useState("");
|
||||
const [exportPasswordConfirm, setExportPasswordConfirm] = useState("");
|
||||
|
||||
// Import password state
|
||||
const [importPassword, setImportPassword] = useState("");
|
||||
|
||||
// CSV is only valid for transaction modes
|
||||
const csvDisabled = exportMode === "categories_only";
|
||||
if (csvDisabled && exportFormat === "csv") {
|
||||
setExportFormat("json");
|
||||
}
|
||||
|
||||
const passwordsMatch = exportPassword === exportPasswordConfirm;
|
||||
const passwordValid = !encryptExport || (exportPassword.length >= 8 && passwordsMatch);
|
||||
|
||||
const handleExport = () => {
|
||||
exportHook.performExport(
|
||||
exportMode,
|
||||
exportFormat,
|
||||
encryptExport ? exportPassword : undefined
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportPasswordSubmit = () => {
|
||||
importHook.readWithPassword(importPassword);
|
||||
setImportPassword("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Database size={18} />
|
||||
{t("settings.dataManagement.title")}
|
||||
</h2>
|
||||
|
||||
{/* === EXPORT SECTION === */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{t("settings.dataManagement.export.title")}
|
||||
</h3>
|
||||
|
||||
{/* Export mode */}
|
||||
<div>
|
||||
<label className="text-sm block mb-1">
|
||||
{t("settings.dataManagement.export.modeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={exportMode}
|
||||
onChange={(e) => setExportMode(e.target.value as ExportMode)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
>
|
||||
<option value="transactions_with_categories">
|
||||
{t("settings.dataManagement.export.modeTransactionsWithCategories")}
|
||||
</option>
|
||||
<option value="transactions_only">
|
||||
{t("settings.dataManagement.export.modeTransactionsOnly")}
|
||||
</option>
|
||||
<option value="categories_only">
|
||||
{t("settings.dataManagement.export.modeCategoriesOnly")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export format */}
|
||||
<div>
|
||||
<label className="text-sm block mb-1">
|
||||
{t("settings.dataManagement.export.formatLabel")}
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value="json"
|
||||
checked={exportFormat === "json"}
|
||||
onChange={() => setExportFormat("json")}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
JSON
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
csvDisabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value="csv"
|
||||
checked={exportFormat === "csv"}
|
||||
onChange={() => setExportFormat("csv")}
|
||||
disabled={csvDisabled}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
CSV
|
||||
{csvDisabled && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
({t("settings.dataManagement.export.csvDisabledNote")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Encryption */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encryptExport}
|
||||
onChange={(e) => {
|
||||
setEncryptExport(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setExportPassword("");
|
||||
setExportPasswordConfirm("");
|
||||
}
|
||||
}}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
<Lock size={14} />
|
||||
{t("settings.dataManagement.export.encryptLabel")}
|
||||
</label>
|
||||
|
||||
{encryptExport && (
|
||||
<div className="space-y-2 ml-6">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.export.passwordPlaceholder")}
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.export.passwordConfirmPlaceholder")}
|
||||
value={exportPasswordConfirm}
|
||||
onChange={(e) => setExportPasswordConfirm(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
/>
|
||||
{exportPassword.length > 0 && exportPassword.length < 8 && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("settings.dataManagement.export.passwordTooShort")}
|
||||
</p>
|
||||
)}
|
||||
{exportPassword.length >= 8 &&
|
||||
exportPasswordConfirm.length > 0 &&
|
||||
!passwordsMatch && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("settings.dataManagement.export.passwordMismatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={
|
||||
exportHook.state.status === "exporting" || !passwordValid
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{exportHook.state.status === "exporting" ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
{t("settings.dataManagement.export.button")}
|
||||
</button>
|
||||
|
||||
{/* Export feedback */}
|
||||
{exportHook.state.status === "success" && (
|
||||
<div className="flex items-center gap-2 text-[var(--positive)] text-sm">
|
||||
<CheckCircle size={14} />
|
||||
{t("settings.dataManagement.export.success")}
|
||||
</div>
|
||||
)}
|
||||
{exportHook.state.status === "error" && (
|
||||
<div className="flex items-center gap-2 text-[var(--negative)] text-sm">
|
||||
<AlertCircle size={14} />
|
||||
{exportHook.state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-[var(--border)]" />
|
||||
|
||||
{/* === IMPORT SECTION === */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{t("settings.dataManagement.import.title")}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.dataManagement.import.description")}
|
||||
</p>
|
||||
|
||||
{/* Import button */}
|
||||
<button
|
||||
onClick={importHook.pickAndRead}
|
||||
disabled={
|
||||
importHook.state.status === "reading" ||
|
||||
importHook.state.status === "importing"
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importHook.state.status === "reading" ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={16} />
|
||||
)}
|
||||
{t("settings.dataManagement.import.button")}
|
||||
</button>
|
||||
|
||||
{/* Password prompt for encrypted files */}
|
||||
{importHook.state.status === "needsPassword" && (
|
||||
<div className="space-y-2 p-3 border border-[var(--border)] rounded-lg">
|
||||
<p className="text-sm">
|
||||
<Lock size={14} className="inline mr-1" />
|
||||
{t("settings.dataManagement.import.passwordRequired")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.import.passwordPlaceholder")}
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && importPassword) handleImportPasswordSubmit();
|
||||
}}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleImportPasswordSubmit}
|
||||
disabled={!importPassword}
|
||||
className="px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 text-sm"
|
||||
>
|
||||
{t("settings.dataManagement.import.decrypt")}
|
||||
</button>
|
||||
<button
|
||||
onClick={importHook.reset}
|
||||
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import feedback */}
|
||||
{importHook.state.status === "success" && (
|
||||
<div className="flex items-center gap-2 text-[var(--positive)] text-sm">
|
||||
<CheckCircle size={14} />
|
||||
{t("settings.dataManagement.import.success")}
|
||||
</div>
|
||||
)}
|
||||
{importHook.state.status === "error" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[var(--negative)] text-sm">
|
||||
<AlertCircle size={14} />
|
||||
{importHook.state.error}
|
||||
</div>
|
||||
<button
|
||||
onClick={importHook.reset}
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{t("settings.dataManagement.import.tryAgain")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import confirmation modal */}
|
||||
{importHook.state.status === "confirming" &&
|
||||
importHook.state.summary &&
|
||||
importHook.state.importType && (
|
||||
<ImportConfirmModal
|
||||
summary={importHook.state.summary}
|
||||
importType={importHook.state.importType}
|
||||
isImporting={false}
|
||||
onConfirm={importHook.executeImport}
|
||||
onCancel={importHook.reset}
|
||||
/>
|
||||
)}
|
||||
{importHook.state.status === "importing" && importHook.state.summary && importHook.state.importType && (
|
||||
<ImportConfirmModal
|
||||
summary={importHook.state.summary}
|
||||
importType={importHook.state.importType}
|
||||
isImporting={true}
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/settings/ImportConfirmModal.tsx
Normal file
168
src/components/settings/ImportConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X, Loader2 } from "lucide-react";
|
||||
import type { ImportSummary, ExportMode } from "../../services/dataExportService";
|
||||
|
||||
interface ImportConfirmModalProps {
|
||||
summary: ImportSummary;
|
||||
importType: ExportMode;
|
||||
isImporting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ImportConfirmModal({
|
||||
summary,
|
||||
importType,
|
||||
isImporting,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ImportConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
|
||||
const willDelete: string[] = [];
|
||||
const willImport: string[] = [];
|
||||
|
||||
if (importType === "categories_only") {
|
||||
willDelete.push(t("settings.dataManagement.import.willDeleteCategories"));
|
||||
if (summary.categoriesCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countCategories", {
|
||||
count: summary.categoriesCount,
|
||||
})
|
||||
);
|
||||
if (summary.suppliersCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countSuppliers", {
|
||||
count: summary.suppliersCount,
|
||||
})
|
||||
);
|
||||
if (summary.keywordsCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countKeywords", {
|
||||
count: summary.keywordsCount,
|
||||
})
|
||||
);
|
||||
} else if (importType === "transactions_with_categories") {
|
||||
willDelete.push(t("settings.dataManagement.import.willDeleteAll"));
|
||||
if (summary.categoriesCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countCategories", {
|
||||
count: summary.categoriesCount,
|
||||
})
|
||||
);
|
||||
if (summary.transactionsCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countTransactions", {
|
||||
count: summary.transactionsCount,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
willDelete.push(t("settings.dataManagement.import.willDeleteTransactions"));
|
||||
if (summary.transactionsCount > 0)
|
||||
willImport.push(
|
||||
t("settings.dataManagement.import.countTransactions", {
|
||||
count: summary.transactionsCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const confirmWord = t("settings.dataManagement.import.confirmWord");
|
||||
const canConfirm = confirmText === confirmWord && !isImporting;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-md mx-4 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 text-[var(--negative)]">
|
||||
<AlertTriangle size={20} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.dataManagement.import.confirmTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isImporting}
|
||||
className="p-1 rounded hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* What will be deleted */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--negative)] mb-1">
|
||||
{t("settings.dataManagement.import.willDeleteLabel")}
|
||||
</p>
|
||||
<ul className="text-sm text-[var(--muted-foreground)] list-disc list-inside">
|
||||
{willDelete.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* What will be imported */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{t("settings.dataManagement.import.willImportLabel")}
|
||||
</p>
|
||||
<ul className="text-sm text-[var(--muted-foreground)] list-disc list-inside">
|
||||
{willImport.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-[var(--negative)]/10 border border-[var(--negative)]/30 rounded-lg p-3">
|
||||
<p className="text-sm text-[var(--negative)]">
|
||||
{t("settings.dataManagement.import.irreversibleWarning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div>
|
||||
<label className="text-sm text-[var(--muted-foreground)] block mb-1">
|
||||
{t("settings.dataManagement.import.typeToConfirm", {
|
||||
word: confirmWord,
|
||||
})}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isImporting && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("settings.dataManagement.import.replaceButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
src/components/shared/ChartContextMenu.tsx
Normal file
79
src/components/shared/ChartContextMenu.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EyeOff, List } from "lucide-react";
|
||||
|
||||
export interface ChartContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
categoryName: string;
|
||||
onHide: () => void;
|
||||
onViewDetails: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ChartContextMenu({
|
||||
x,
|
||||
y,
|
||||
categoryName,
|
||||
onHide,
|
||||
onViewDetails,
|
||||
onClose,
|
||||
}: ChartContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
menuRef.current.style.left = `${x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
menuRef.current.style.top = `${y - rect.height}px`;
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
||||
{categoryName}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onViewDetails(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("charts.viewTransactions")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onHide(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
{t("charts.hideCategory")}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
224
src/components/shared/TransactionDetailModal.tsx
Normal file
224
src/components/shared/TransactionDetailModal.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Loader2, ArrowUp, ArrowDown, Eye, EyeOff } from "lucide-react";
|
||||
import { getTransactionsByCategory } from "../../services/dashboardService";
|
||||
import type { TransactionRow } from "../../shared/types";
|
||||
|
||||
const cadFormatter = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
});
|
||||
|
||||
type SortColumn = "date" | "description" | "amount";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
interface TransactionDetailModalProps {
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
categoryColor: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TransactionDetailModal({
|
||||
categoryId,
|
||||
categoryName,
|
||||
categoryColor,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onClose,
|
||||
}: TransactionDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTransactionsByCategory(categoryId, dateFrom, dateTo);
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [categoryId, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [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 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(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full inline-block flex-shrink-0"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">{categoryName}</h2>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
({rows.length} {t("charts.transactions")})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowAmounts((v) => !v)}
|
||||
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors text-[var(--muted-foreground)]"
|
||||
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-6 py-4 text-[var(--negative)]">{error}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && rows.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("dashboard.noData")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && rows.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)] text-[var(--muted-foreground)]">
|
||||
<th
|
||||
className={`text-left ${thClass}`}
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<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 truncate max-w-[300px]">{row.description}</td>
|
||||
{showAmounts && (
|
||||
<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>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{showAmounts && (
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
|
||||
<td className={`px-6 py-3 text-right ${
|
||||
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}>
|
||||
{cadFormatter.format(total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
286
src/components/transactions/SplitAdjustmentModal.tsx
Normal file
286
src/components/transactions/SplitAdjustmentModal.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2 } from "lucide-react";
|
||||
import type { TransactionRow, Category, SplitChild } from "../../shared/types";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
|
||||
interface SplitEntry {
|
||||
category_id: number | null;
|
||||
amount: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
transaction: TransactionRow;
|
||||
categories: Category[];
|
||||
onLoadChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||
onSave: (
|
||||
parentId: number,
|
||||
entries: Array<{ category_id: number; amount: number; description: string }>
|
||||
) => Promise<void>;
|
||||
onDelete: (parentId: number) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SplitAdjustmentModal({
|
||||
transaction,
|
||||
categories,
|
||||
onLoadChildren,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<SplitEntry[]>([
|
||||
{ category_id: null, amount: "", description: "" },
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const isExpense = transaction.amount < 0;
|
||||
const absOriginal = Math.abs(transaction.amount);
|
||||
|
||||
// Load existing children if this is already split
|
||||
useEffect(() => {
|
||||
if (!transaction.is_split) return;
|
||||
setLoading(true);
|
||||
onLoadChildren(transaction.id).then((children) => {
|
||||
// Filter out the offset child (same category as parent)
|
||||
const splitEntries = children.filter(
|
||||
(c) => c.category_id !== transaction.category_id || Math.sign(c.amount) === Math.sign(transaction.amount)
|
||||
);
|
||||
if (splitEntries.length > 0) {
|
||||
setEntries(
|
||||
splitEntries.map((c) => ({
|
||||
category_id: c.category_id,
|
||||
amount: Math.abs(c.amount).toFixed(2),
|
||||
description: c.description,
|
||||
}))
|
||||
);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const parsedAmounts = useMemo(
|
||||
() => entries.map((e) => parseFloat(e.amount) || 0),
|
||||
[entries]
|
||||
);
|
||||
const splitTotal = useMemo(
|
||||
() => parsedAmounts.reduce((s, a) => s + a, 0),
|
||||
[parsedAmounts]
|
||||
);
|
||||
const remainder = +(absOriginal - splitTotal).toFixed(2);
|
||||
|
||||
const isValid =
|
||||
entries.length > 0 &&
|
||||
entries.every((e) => e.category_id !== null && (parseFloat(e.amount) || 0) > 0) &&
|
||||
splitTotal > 0 &&
|
||||
remainder >= 0;
|
||||
|
||||
const updateEntry = (index: number, field: keyof SplitEntry, value: string | number | null) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, i) => (i === index ? { ...e, [field]: value } : e))
|
||||
);
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries((prev) => [...prev, { category_id: null, amount: "", description: "" }]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isValid) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(
|
||||
transaction.id,
|
||||
entries.map((e) => ({
|
||||
category_id: e.category_id!,
|
||||
amount: isExpense
|
||||
? -Math.abs(parseFloat(e.amount))
|
||||
: Math.abs(parseFloat(e.amount)),
|
||||
description: e.description || transaction.description,
|
||||
}))
|
||||
);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onDelete(transaction.id);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-lg border border-[var(--border)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("transactions.splitAdjustment")}</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] truncate max-w-xs">
|
||||
{transaction.description}
|
||||
<span
|
||||
className={`ml-2 font-mono ${
|
||||
transaction.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
{transaction.amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Original category remainder row */}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-[var(--muted)] text-sm">
|
||||
<span className="shrink-0 text-[var(--muted-foreground)]">
|
||||
{t("transactions.splitBase")}:
|
||||
</span>
|
||||
{transaction.category_color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: transaction.category_color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{transaction.category_name}</span>
|
||||
<span className="ml-auto font-mono whitespace-nowrap">
|
||||
{isExpense ? "-" : ""}
|
||||
{remainder.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Split entry rows */}
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CategoryCombobox
|
||||
categories={categories}
|
||||
value={entry.category_id}
|
||||
onChange={(id) => updateEntry(index, "category_id", id)}
|
||||
placeholder={t("transactions.splitCategory")}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={entry.amount}
|
||||
onChange={(e) => updateEntry(index, "amount", e.target.value)}
|
||||
placeholder={t("transactions.splitAmount")}
|
||||
className="w-24 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-right font-mono focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.description}
|
||||
onChange={(e) => updateEntry(index, "description", e.target.value)}
|
||||
placeholder={t("transactions.splitDescription")}
|
||||
className="w-32 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeEntry(index)}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add row */}
|
||||
<button
|
||||
onClick={addEntry}
|
||||
className="flex items-center gap-1 text-sm text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("transactions.splitAddRow")}
|
||||
</button>
|
||||
|
||||
{/* Validation message */}
|
||||
{remainder < 0 && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("transactions.splitTotal")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border)]">
|
||||
<div>
|
||||
{transaction.is_split && !confirmDelete && (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="text-sm text-[var(--negative)] hover:underline"
|
||||
>
|
||||
{t("transactions.splitRemove")}
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--negative)]">
|
||||
{t("transactions.splitDeleteConfirm")}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="px-2 py-1 text-xs rounded border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || saving}
|
||||
className="px-4 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,44 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { TransactionFilters, Category, ImportSource } from "../../shared/types";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
|
||||
type QuickPeriod = "month" | "3months" | "6months" | "year" | "all";
|
||||
const PERIODS: QuickPeriod[] = ["month", "3months", "6months", "year", "all"];
|
||||
|
||||
function computePeriodDates(period: QuickPeriod): { dateFrom: string | null; dateTo: string | null } {
|
||||
if (period === "all") return { dateFrom: null, dateTo: null };
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
if (period === "year") return { dateFrom: `${year}-01-01`, dateTo: null };
|
||||
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;
|
||||
}
|
||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||
return { dateFrom, dateTo: null };
|
||||
}
|
||||
|
||||
function detectActivePeriod(filters: TransactionFilters): QuickPeriod | null {
|
||||
if (filters.dateTo) return null;
|
||||
if (!filters.dateFrom) return "all";
|
||||
for (const p of PERIODS) {
|
||||
const { dateFrom } = computePeriodDates(p);
|
||||
if (dateFrom === filters.dateFrom) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TransactionFilterBarProps {
|
||||
filters: TransactionFilters;
|
||||
categories: Category[];
|
||||
|
|
@ -19,6 +54,17 @@ export default function TransactionFilterBar({
|
|||
}: TransactionFilterBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const activePeriod = detectActivePeriod(filters);
|
||||
|
||||
const handlePeriodChange = useCallback(
|
||||
(period: QuickPeriod) => {
|
||||
const { dateFrom, dateTo } = computePeriodDates(period);
|
||||
onFilterChange("dateFrom", dateFrom);
|
||||
onFilterChange("dateTo", dateTo);
|
||||
},
|
||||
[onFilterChange]
|
||||
);
|
||||
|
||||
const categoryExtras = useMemo(
|
||||
() => [
|
||||
{ value: "", label: t("transactions.filters.allCategories") },
|
||||
|
|
@ -37,6 +83,23 @@ export default function TransactionFilterBar({
|
|||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4">
|
||||
{/* Period quick-select */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePeriodChange(p)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === activePeriod
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--background)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t(`dashboard.period.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { Fragment, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronUp, ChevronDown, MessageSquare, Tag } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react";
|
||||
import type {
|
||||
TransactionRow,
|
||||
TransactionSort,
|
||||
Category,
|
||||
SplitChild,
|
||||
} from "../../shared/types";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||
|
||||
interface TransactionTableProps {
|
||||
rows: TransactionRow[];
|
||||
|
|
@ -16,6 +18,9 @@ interface TransactionTableProps {
|
|||
onCategoryChange: (txId: number, categoryId: number | null) => void;
|
||||
onNotesChange: (txId: number, notes: string) => void;
|
||||
onAddKeyword: (categoryId: number, keyword: string) => Promise<void>;
|
||||
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function SortIcon({
|
||||
|
|
@ -42,6 +47,9 @@ export default function TransactionTable({
|
|||
onCategoryChange,
|
||||
onNotesChange,
|
||||
onAddKeyword,
|
||||
onLoadSplitChildren,
|
||||
onSaveSplit,
|
||||
onDeleteSplit,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
|
@ -49,6 +57,7 @@ export default function TransactionTable({
|
|||
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
|
||||
const [keywordText, setKeywordText] = useState("");
|
||||
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
|
||||
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
|
||||
const noCategoryExtra = useMemo(
|
||||
() => [{ value: "", label: t("transactions.table.noCategory") }],
|
||||
[t]
|
||||
|
|
@ -155,17 +164,30 @@ export default function TransactionTable({
|
|||
onExtraSelect={() => onCategoryChange(row.id, null)}
|
||||
/>
|
||||
{row.category_id !== null && (
|
||||
<button
|
||||
onClick={() => toggleKeyword(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
keywordSaved === row.id
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
|
||||
>
|
||||
<Tag size={14} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleKeyword(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
keywordSaved === row.id
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
|
||||
>
|
||||
<Tag size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSplitRow(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
||||
row.is_split
|
||||
? "text-orange-500"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={t("transactions.splitAdjustment")}
|
||||
>
|
||||
<Split size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -174,7 +196,7 @@ export default function TransactionTable({
|
|||
onClick={() => toggleNotes(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
|
||||
row.notes
|
||||
? "text-[var(--primary)]"
|
||||
? "text-orange-500"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={t("transactions.notes.placeholder")}
|
||||
|
|
@ -245,6 +267,16 @@ export default function TransactionTable({
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{splitRow && (
|
||||
<SplitAdjustmentModal
|
||||
transaction={splitRow}
|
||||
categories={categories}
|
||||
onLoadChildren={onLoadSplitChildren}
|
||||
onSave={onSaveSplit}
|
||||
onDelete={onDeleteSplit}
|
||||
onClose={() => setSplitRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
245
src/contexts/ProfileContext.tsx
Normal file
245
src/contexts/ProfileContext.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { createContext, useContext, useEffect, useReducer, useCallback, type ReactNode } from "react";
|
||||
import {
|
||||
loadProfiles,
|
||||
saveProfiles,
|
||||
deleteProfileDb,
|
||||
getNewProfileInitSql,
|
||||
hashPin,
|
||||
type Profile,
|
||||
type ProfilesConfig,
|
||||
} from "../services/profileService";
|
||||
import { connectToProfile, initializeNewProfileDb, closeDb } from "../services/db";
|
||||
|
||||
interface ProfileState {
|
||||
config: ProfilesConfig | null;
|
||||
isLoading: boolean;
|
||||
refreshKey: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ProfileAction =
|
||||
| { type: "SET_CONFIG"; config: ProfilesConfig }
|
||||
| { type: "SET_LOADING"; isLoading: boolean }
|
||||
| { type: "SET_ERROR"; error: string | null }
|
||||
| { type: "INCREMENT_REFRESH" };
|
||||
|
||||
function reducer(state: ProfileState, action: ProfileAction): ProfileState {
|
||||
switch (action.type) {
|
||||
case "SET_CONFIG":
|
||||
return { ...state, config: action.config, error: null };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.isLoading };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error, isLoading: false };
|
||||
case "INCREMENT_REFRESH":
|
||||
return { ...state, refreshKey: state.refreshKey + 1 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileContextValue {
|
||||
profiles: Profile[];
|
||||
activeProfile: Profile | null;
|
||||
isLoading: boolean;
|
||||
refreshKey: number;
|
||||
error: string | null;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
||||
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
|
||||
deleteProfile: (id: string) => Promise<void>;
|
||||
setPin: (id: string, pin: string | null) => Promise<void>;
|
||||
connectActiveProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
||||
|
||||
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
config: null,
|
||||
isLoading: true,
|
||||
refreshKey: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const activeProfile = state.config?.profiles.find(
|
||||
(p) => p.id === state.config?.active_profile_id
|
||||
) ?? null;
|
||||
|
||||
// Load profiles on mount
|
||||
useEffect(() => {
|
||||
loadProfiles()
|
||||
.then((config) => {
|
||||
dispatch({ type: "SET_CONFIG", config });
|
||||
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const connectActiveProfile = useCallback(async () => {
|
||||
if (!state.config) return;
|
||||
const profile = state.config.profiles.find(
|
||||
(p) => p.id === state.config!.active_profile_id
|
||||
);
|
||||
if (!profile) return;
|
||||
await connectToProfile(profile.db_filename);
|
||||
}, [state.config]);
|
||||
|
||||
const switchProfile = useCallback(async (id: string) => {
|
||||
if (!state.config) return;
|
||||
const profile = state.config.profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
|
||||
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||
try {
|
||||
await closeDb();
|
||||
await connectToProfile(profile.db_filename);
|
||||
const newConfig = { ...state.config, active_profile_id: id };
|
||||
await saveProfiles(newConfig);
|
||||
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||
dispatch({ type: "INCREMENT_REFRESH" });
|
||||
} catch (err) {
|
||||
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||
}
|
||||
}, [state.config]);
|
||||
|
||||
const createProfile = useCallback(async (name: string, color: string, pin?: string) => {
|
||||
if (!state.config) return;
|
||||
|
||||
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const dbFilename = `profile_${id.split("-")[0]}.db`;
|
||||
const pinHash = pin ? await hashPin(pin) : null;
|
||||
const now = Date.now().toString();
|
||||
|
||||
const newProfile: Profile = {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
pin_hash: pinHash,
|
||||
db_filename: dbFilename,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
// Initialize the new database
|
||||
const sqlStatements = await getNewProfileInitSql();
|
||||
await initializeNewProfileDb(dbFilename, sqlStatements);
|
||||
|
||||
// Reconnect to the current active profile's DB
|
||||
const currentProfile = state.config.profiles.find(
|
||||
(p) => p.id === state.config!.active_profile_id
|
||||
);
|
||||
if (currentProfile) {
|
||||
await connectToProfile(currentProfile.db_filename);
|
||||
}
|
||||
|
||||
const newConfig: ProfilesConfig = {
|
||||
...state.config,
|
||||
profiles: [...state.config.profiles, newProfile],
|
||||
};
|
||||
await saveProfiles(newConfig);
|
||||
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||
} catch (err) {
|
||||
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||
}
|
||||
}, [state.config]);
|
||||
|
||||
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
|
||||
if (!state.config) return;
|
||||
|
||||
const newProfiles = state.config.profiles.map((p) =>
|
||||
p.id === id ? { ...p, ...updates } : p
|
||||
);
|
||||
const newConfig = { ...state.config, profiles: newProfiles };
|
||||
await saveProfiles(newConfig);
|
||||
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||
}, [state.config]);
|
||||
|
||||
const deleteProfile = useCallback(async (id: string) => {
|
||||
if (!state.config) return;
|
||||
const profile = state.config.profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
if (profile.db_filename === "simpl_resultat.db") return;
|
||||
|
||||
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||
try {
|
||||
// If deleting the active profile, switch to default first
|
||||
if (state.config.active_profile_id === id) {
|
||||
const defaultProfile = state.config.profiles.find(
|
||||
(p) => p.db_filename === "simpl_resultat.db"
|
||||
);
|
||||
if (defaultProfile) {
|
||||
await closeDb();
|
||||
await connectToProfile(defaultProfile.db_filename);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteProfileDb(profile.db_filename);
|
||||
|
||||
const newProfiles = state.config.profiles.filter((p) => p.id !== id);
|
||||
const newActiveId =
|
||||
state.config.active_profile_id === id
|
||||
? newProfiles[0]?.id ?? "default"
|
||||
: state.config.active_profile_id;
|
||||
|
||||
const newConfig: ProfilesConfig = {
|
||||
active_profile_id: newActiveId,
|
||||
profiles: newProfiles,
|
||||
};
|
||||
await saveProfiles(newConfig);
|
||||
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||
if (state.config.active_profile_id === id) {
|
||||
dispatch({ type: "INCREMENT_REFRESH" });
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||
}
|
||||
}, [state.config]);
|
||||
|
||||
const setPin = useCallback(async (id: string, pin: string | null) => {
|
||||
if (!state.config) return;
|
||||
|
||||
const pinHash = pin ? await hashPin(pin) : null;
|
||||
const newProfiles = state.config.profiles.map((p) =>
|
||||
p.id === id ? { ...p, pin_hash: pinHash } : p
|
||||
);
|
||||
const newConfig = { ...state.config, profiles: newProfiles };
|
||||
await saveProfiles(newConfig);
|
||||
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||
}, [state.config]);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider
|
||||
value={{
|
||||
profiles: state.config?.profiles ?? [],
|
||||
activeProfile,
|
||||
isLoading: state.isLoading,
|
||||
refreshKey: state.refreshKey,
|
||||
error: state.error,
|
||||
switchProfile,
|
||||
createProfile,
|
||||
updateProfile,
|
||||
deleteProfile,
|
||||
setPin,
|
||||
connectActiveProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
const ctx = useContext(ProfileContext);
|
||||
if (!ctx) throw new Error("useProfile must be used within ProfileProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type { BudgetRow, BudgetTemplate } from "../shared/types";
|
||||
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||
import {
|
||||
getActiveCategories,
|
||||
getBudgetEntriesForMonth,
|
||||
getActualsByCategory,
|
||||
getAllActiveCategories,
|
||||
getBudgetEntriesForYear,
|
||||
getActualTotalsForYear,
|
||||
upsertBudgetEntry,
|
||||
deleteBudgetEntry,
|
||||
upsertBudgetEntriesForYear,
|
||||
getAllTemplates,
|
||||
saveAsTemplate as saveAsTemplateSvc,
|
||||
applyTemplate as applyTemplateSvc,
|
||||
|
|
@ -14,8 +14,7 @@ import {
|
|||
|
||||
interface BudgetState {
|
||||
year: number;
|
||||
month: number;
|
||||
rows: BudgetRow[];
|
||||
rows: BudgetYearRow[];
|
||||
templates: BudgetTemplate[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
|
|
@ -26,14 +25,12 @@ type BudgetAction =
|
|||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_SAVING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_DATA"; payload: { rows: BudgetRow[]; templates: BudgetTemplate[] } }
|
||||
| { type: "NAVIGATE_MONTH"; payload: { year: number; month: number } };
|
||||
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
||||
| { type: "SET_YEAR"; payload: number };
|
||||
|
||||
function initialState(): BudgetState {
|
||||
const now = new Date();
|
||||
return {
|
||||
year: now.getFullYear(),
|
||||
month: now.getMonth() + 1,
|
||||
year: new Date().getFullYear(),
|
||||
rows: [],
|
||||
templates: [],
|
||||
isLoading: false,
|
||||
|
|
@ -57,8 +54,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
|||
templates: action.payload.templates,
|
||||
isLoading: false,
|
||||
};
|
||||
case "NAVIGATE_MONTH":
|
||||
return { ...state, year: action.payload.year, month: action.payload.month };
|
||||
case "SET_YEAR":
|
||||
return { ...state, year: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -70,52 +67,289 @@ export function useBudget() {
|
|||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const refreshData = useCallback(async (year: number, month: number) => {
|
||||
const refreshData = useCallback(async (year: number) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const [categories, entries, actuals, templates] = await Promise.all([
|
||||
getActiveCategories(),
|
||||
getBudgetEntriesForMonth(year, month),
|
||||
getActualsByCategory(year, month),
|
||||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||
getAllActiveCategories(),
|
||||
getBudgetEntriesForYear(year),
|
||||
getActualTotalsForYear(year - 1),
|
||||
getAllTemplates(),
|
||||
]);
|
||||
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.category_id, e]));
|
||||
const actualMap = new Map(actuals.map((a) => [a.category_id, a.actual]));
|
||||
// Build a map: categoryId -> month(1-12) -> amount
|
||||
const entryMap = new Map<number, Map<number, number>>();
|
||||
for (const e of entries) {
|
||||
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
|
||||
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||
}
|
||||
|
||||
const rows: BudgetRow[] = categories.map((cat) => {
|
||||
const entry = entryMap.get(cat.id);
|
||||
const planned = entry?.amount ?? 0;
|
||||
const actual = actualMap.get(cat.id) ?? 0;
|
||||
// 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);
|
||||
}
|
||||
|
||||
let difference: number;
|
||||
if (cat.type === "income") {
|
||||
difference = actual - planned;
|
||||
} else {
|
||||
difference = planned - Math.abs(actual);
|
||||
// Helper: build months array from entryMap
|
||||
const buildMonths = (catId: number) => {
|
||||
const monthMap = entryMap.get(catId);
|
||||
const months: number[] = [];
|
||||
let annual = 0;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const val = monthMap?.get(m) ?? 0;
|
||||
months.push(val);
|
||||
annual += val;
|
||||
}
|
||||
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
|
||||
return { months, annual, previousYearTotal };
|
||||
};
|
||||
|
||||
// Index categories by id and group children by parent_id
|
||||
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
||||
const childrenByParent = new Map<number, typeof allCategories>();
|
||||
for (const cat of allCategories) {
|
||||
if (cat.parent_id) {
|
||||
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
|
||||
childrenByParent.get(cat.parent_id)!.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
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,
|
||||
planned,
|
||||
actual,
|
||||
difference,
|
||||
notes: entry?.notes,
|
||||
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
|
||||
const topLevel = allCategories.filter((c) => !c.parent_id);
|
||||
|
||||
for (const cat of topLevel) {
|
||||
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 (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||
// Standalone leaf (no children) — regular editable row
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
rows.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
category_color: cat.color || "#9ca3af",
|
||||
category_type: cat.type,
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
depth: 0,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||
const allChildRows: BudgetYearRow[] = [];
|
||||
|
||||
// If parent is also inputable, create a "(direct)" fake-child row
|
||||
if (cat.is_inputable) {
|
||||
const { months, annual, previousYearTotal } = buildMonths(cat.id);
|
||||
allChildRows.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: 1,
|
||||
months,
|
||||
annual,
|
||||
previousYearTotal,
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of inputableChildren) {
|
||||
const grandchildren = childrenByParent.get(child.id) || [];
|
||||
if (grandchildren.length === 0) {
|
||||
// Simple leaf at depth 1
|
||||
const { months, annual, previousYearTotal } = buildMonths(child.id);
|
||||
allChildRows.push({
|
||||
category_id: child.id,
|
||||
category_name: child.name,
|
||||
category_color: child.color || cat.color || "#9ca3af",
|
||||
category_type: child.type,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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[];
|
||||
let parentAnnual = 0;
|
||||
let parentPrevYear = 0;
|
||||
for (const cr of leafRows) {
|
||||
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
|
||||
parentAnnual += cr.annual;
|
||||
parentPrevYear += cr.previousYearTotal;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
category_color: cat.color || "#9ca3af",
|
||||
category_type: cat.type,
|
||||
parent_id: null,
|
||||
is_parent: true,
|
||||
depth: 0,
|
||||
months: parentMonths,
|
||||
annual: parentAnnual,
|
||||
previousYearTotal: parentPrevYear,
|
||||
});
|
||||
|
||||
// Sort children alphabetically, but keep "(direct)" first
|
||||
allChildRows.sort((a, b) => {
|
||||
if (a.category_id === cat.id && !a.is_parent) return -1;
|
||||
if (b.category_id === cat.id && !b.is_parent) return 1;
|
||||
return a.category_name.localeCompare(b.category_name);
|
||||
});
|
||||
|
||||
rows.push(...allChildRows);
|
||||
}
|
||||
// else: non-inputable parent with no inputable children — skip
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
const groupA = getTopGroupId(a);
|
||||
const groupB = getTopGroupId(b);
|
||||
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 ?? "");
|
||||
}
|
||||
// Same group: sort by depth, then parent before children at same depth
|
||||
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
|
||||
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 (b.parent_id && b.category_id === b.parent_id) return 1;
|
||||
return a.category_name.localeCompare(b.category_name);
|
||||
});
|
||||
|
||||
|
|
@ -130,28 +364,19 @@ export function useBudget() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData(state.year, state.month);
|
||||
}, [state.year, state.month, refreshData]);
|
||||
refreshData(state.year);
|
||||
}, [state.year, refreshData]);
|
||||
|
||||
const navigateMonth = useCallback((delta: -1 | 1) => {
|
||||
let newMonth = state.month + delta;
|
||||
let newYear = state.year;
|
||||
if (newMonth < 1) {
|
||||
newMonth = 12;
|
||||
newYear--;
|
||||
} else if (newMonth > 12) {
|
||||
newMonth = 1;
|
||||
newYear++;
|
||||
}
|
||||
dispatch({ type: "NAVIGATE_MONTH", payload: { year: newYear, month: newMonth } });
|
||||
}, [state.year, state.month]);
|
||||
const navigateYear = useCallback((delta: -1 | 1) => {
|
||||
dispatch({ type: "SET_YEAR", payload: state.year + delta });
|
||||
}, [state.year]);
|
||||
|
||||
const updatePlanned = useCallback(
|
||||
async (categoryId: number, amount: number, notes?: string) => {
|
||||
async (categoryId: number, month: number, amount: number) => {
|
||||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await upsertBudgetEntry(categoryId, state.year, state.month, amount, notes);
|
||||
await refreshData(state.year, state.month);
|
||||
await upsertBudgetEntry(categoryId, state.year, month, amount);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -161,15 +386,21 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, state.month, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const removePlanned = useCallback(
|
||||
async (categoryId: number) => {
|
||||
const splitEvenly = useCallback(
|
||||
async (categoryId: number, annualAmount: number) => {
|
||||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await deleteBudgetEntry(categoryId, state.year, state.month);
|
||||
await refreshData(state.year, state.month);
|
||||
const base = Math.floor((annualAmount / 12) * 100) / 100;
|
||||
const remainder = Math.round((annualAmount - base * 12) * 100);
|
||||
const amounts: number[] = [];
|
||||
for (let m = 0; m < 12; m++) {
|
||||
amounts.push(m < remainder ? base + 0.01 : base);
|
||||
}
|
||||
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -179,18 +410,20 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, state.month, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const saveTemplate = useCallback(
|
||||
async (name: string, description?: string) => {
|
||||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
// Save template from January values (template is a single-month snapshot)
|
||||
// Exclude parent subtotal rows (they're computed, not real entries)
|
||||
const entries = state.rows
|
||||
.filter((r) => r.planned !== 0)
|
||||
.map((r) => ({ category_id: r.category_id, amount: r.planned }));
|
||||
.filter((r) => !r.is_parent && r.months[0] !== 0)
|
||||
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
|
||||
await saveAsTemplateSvc(name, description, entries);
|
||||
await refreshData(state.year, state.month);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -200,15 +433,15 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.rows, state.year, state.month, refreshData]
|
||||
[state.rows, state.year, refreshData]
|
||||
);
|
||||
|
||||
const applyTemplate = useCallback(
|
||||
async (templateId: number) => {
|
||||
async (templateId: number, month: number) => {
|
||||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await applyTemplateSvc(templateId, state.year, state.month);
|
||||
await refreshData(state.year, state.month);
|
||||
await applyTemplateSvc(templateId, state.year, month);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -218,7 +451,27 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, state.month, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const applyTemplateAllMonths = useCallback(
|
||||
async (templateId: number) => {
|
||||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
await applyTemplateSvc(templateId, state.year, m);
|
||||
}
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const deleteTemplate = useCallback(
|
||||
|
|
@ -226,7 +479,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await deleteTemplateSvc(templateId);
|
||||
await refreshData(state.year, state.month);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -236,16 +489,17 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, state.month, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
navigateMonth,
|
||||
navigateYear,
|
||||
updatePlanned,
|
||||
removePlanned,
|
||||
splitEvenly,
|
||||
saveTemplate,
|
||||
applyTemplate,
|
||||
applyTemplateAllMonths,
|
||||
deleteTemplate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import {
|
|||
updateKeyword,
|
||||
deactivateKeyword,
|
||||
reinitializeCategories as reinitializeCategoriesSvc,
|
||||
hasDuplicateSortOrders,
|
||||
fixDuplicateSortOrders,
|
||||
getNextSortOrder,
|
||||
updateCategorySortOrders,
|
||||
} from "../services/categoryService";
|
||||
|
||||
interface CategoriesState {
|
||||
|
|
@ -35,6 +39,7 @@ type CategoriesAction =
|
|||
| { type: "SET_SAVING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
|
||||
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
|
||||
| { type: "SELECT_CATEGORY"; payload: number | null }
|
||||
| { type: "SET_KEYWORDS"; payload: Keyword[] }
|
||||
| { type: "START_CREATING" }
|
||||
|
|
@ -72,6 +77,20 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
|
|||
return roots;
|
||||
}
|
||||
|
||||
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
|
||||
const result: CategoryTreeNode[] = [];
|
||||
function recurse(nodes: CategoryTreeNode[]) {
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children.length > 0) {
|
||||
recurse(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
recurse(tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
function reducer(state: CategoriesState, action: CategoriesAction): CategoriesState {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
|
|
@ -82,6 +101,8 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
|
|||
return { ...state, error: action.payload, isLoading: false, isSaving: false };
|
||||
case "SET_CATEGORIES":
|
||||
return { ...state, categories: action.payload.flat, tree: action.payload.tree, isLoading: false };
|
||||
case "SET_TREE":
|
||||
return { ...state, tree: action.payload, categories: flattenTreeToCategories(action.payload) };
|
||||
case "SELECT_CATEGORY":
|
||||
return { ...state, selectedCategoryId: action.payload, editingCategory: null, isCreating: false, keywords: [] };
|
||||
case "SET_KEYWORDS":
|
||||
|
|
@ -91,7 +112,7 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
|
|||
...state,
|
||||
isCreating: true,
|
||||
selectedCategoryId: null,
|
||||
editingCategory: { name: "", type: "expense", color: "#4A90A4", parent_id: null, sort_order: 0 },
|
||||
editingCategory: { name: "", type: "expense", color: "#4A90A4", parent_id: null, is_inputable: true, sort_order: 0 },
|
||||
keywords: [],
|
||||
};
|
||||
case "START_EDITING":
|
||||
|
|
@ -106,6 +127,7 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
|
|||
export function useCategories() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
const duplicateCheckDone = useRef(false);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
|
|
@ -113,6 +135,14 @@ export function useCategories() {
|
|||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
if (!duplicateCheckDone.current) {
|
||||
duplicateCheckDone.current = true;
|
||||
const hasDups = await hasDuplicateSortOrders();
|
||||
if (hasDups) {
|
||||
await fixDuplicateSortOrders();
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await getAllCategoriesWithCounts();
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
|
||||
|
|
@ -154,6 +184,7 @@ export function useCategories() {
|
|||
type: cat.type,
|
||||
color: cat.color ?? "#4A90A4",
|
||||
parent_id: cat.parent_id,
|
||||
is_inputable: cat.is_inputable,
|
||||
sort_order: cat.sort_order,
|
||||
},
|
||||
});
|
||||
|
|
@ -170,7 +201,8 @@ export function useCategories() {
|
|||
|
||||
try {
|
||||
if (state.isCreating) {
|
||||
const newId = await createCategory(formData);
|
||||
const sortOrder = await getNextSortOrder(formData.parent_id);
|
||||
const newId = await createCategory({ ...formData, sort_order: sortOrder });
|
||||
await loadCategories();
|
||||
await selectCategory(newId);
|
||||
} else if (state.selectedCategoryId !== null) {
|
||||
|
|
@ -225,6 +257,76 @@ export function useCategories() {
|
|||
}
|
||||
}, [loadCategories]);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
async (categoryId: number, newParentId: number | null, newIndex: number) => {
|
||||
// Clone current tree
|
||||
const cloneNode = (n: CategoryTreeNode): CategoryTreeNode => ({
|
||||
...n,
|
||||
children: n.children.map(cloneNode),
|
||||
});
|
||||
const newTree = state.tree.map(cloneNode);
|
||||
|
||||
// Recursively find and remove the category from its current position
|
||||
function removeFromList(list: CategoryTreeNode[]): CategoryTreeNode | null {
|
||||
const idx = list.findIndex((n) => n.id === categoryId);
|
||||
if (idx !== -1) {
|
||||
return list.splice(idx, 1)[0];
|
||||
}
|
||||
for (const node of list) {
|
||||
const found = removeFromList(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const movedNode = removeFromList(newTree);
|
||||
|
||||
if (!movedNode) return;
|
||||
|
||||
// Update parent_id
|
||||
movedNode.parent_id = newParentId;
|
||||
|
||||
// Insert at new position
|
||||
if (newParentId === null) {
|
||||
newTree.splice(newIndex, 0, movedNode);
|
||||
} else {
|
||||
// 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;
|
||||
newParent.children.splice(newIndex, 0, movedNode);
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
dispatch({ type: "SET_TREE", payload: newTree });
|
||||
|
||||
// Compute batch updates for all nodes in the tree (3 levels)
|
||||
const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
|
||||
|
||||
function collectUpdates(nodes: CategoryTreeNode[], parentId: number | null) {
|
||||
nodes.forEach((n, i) => {
|
||||
updates.push({ id: n.id, sort_order: i + 1, parent_id: parentId });
|
||||
collectUpdates(n.children, n.id);
|
||||
});
|
||||
}
|
||||
collectUpdates(newTree, null);
|
||||
|
||||
try {
|
||||
await updateCategorySortOrders(updates);
|
||||
} catch {
|
||||
// Revert on error
|
||||
await loadCategories();
|
||||
}
|
||||
},
|
||||
[state.tree, loadCategories]
|
||||
);
|
||||
|
||||
const loadKeywords = useCallback(async (categoryId: number) => {
|
||||
try {
|
||||
const kws = await getKeywordsByCategoryId(categoryId);
|
||||
|
|
@ -289,5 +391,6 @@ export function useCategories() {
|
|||
editKeyword,
|
||||
removeKeyword,
|
||||
reinitializeCategories,
|
||||
moveCategory,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,27 @@ import type {
|
|||
DashboardPeriod,
|
||||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
RecentTransaction,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getExpensesByCategory,
|
||||
getRecentTransactions,
|
||||
} from "../services/dashboardService";
|
||||
import { getCategoryOverTime } from "../services/reportService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary;
|
||||
categoryBreakdown: CategoryBreakdownItem[];
|
||||
recentTransactions: RecentTransaction[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
period: DashboardPeriod;
|
||||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -28,16 +36,28 @@ type DashboardAction =
|
|||
payload: {
|
||||
summary: DashboardSummary;
|
||||
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 = {
|
||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||
categoryBreakdown: [],
|
||||
recentTransactions: [],
|
||||
period: "month",
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
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,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -53,66 +73,47 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
|||
...state,
|
||||
summary: action.payload.summary,
|
||||
categoryBreakdown: action.payload.categoryBreakdown,
|
||||
recentTransactions: action.payload.recentTransactions,
|
||||
categoryOverTime: action.payload.categoryOverTime,
|
||||
budgetVsActual: action.payload.budgetVsActual,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_PERIOD":
|
||||
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:
|
||||
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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
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;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const { dateFrom, dateTo } = computeDateRange(period);
|
||||
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
||||
getDashboardSummary(dateFrom, dateTo),
|
||||
getExpensesByCategory(dateFrom, dateTo),
|
||||
getRecentTransactions(10),
|
||||
getCategoryOverTime(dateFrom, dateTo),
|
||||
getBudgetVsActualData(bYear, bMonth),
|
||||
]);
|
||||
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
|
||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
|
|
@ -123,12 +124,20 @@ export function useDashboard() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.period);
|
||||
}, [state.period, fetchData]);
|
||||
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
||||
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
||||
|
||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
122
src/hooks/useDataExport.ts
Normal file
122
src/hooks/useDataExport.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useReducer, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import {
|
||||
getExportCategories,
|
||||
getExportSuppliers,
|
||||
getExportKeywords,
|
||||
getExportTransactions,
|
||||
serializeToJson,
|
||||
serializeTransactionsToCsv,
|
||||
type ExportMode,
|
||||
type ExportFormat,
|
||||
} from "../services/dataExportService";
|
||||
|
||||
type ExportStatus = "idle" | "exporting" | "success" | "error";
|
||||
|
||||
interface ExportState {
|
||||
status: ExportStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ExportAction =
|
||||
| { type: "EXPORT_START" }
|
||||
| { type: "EXPORT_SUCCESS" }
|
||||
| { type: "EXPORT_ERROR"; error: string }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: ExportState = {
|
||||
status: "idle",
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(_state: ExportState, action: ExportAction): ExportState {
|
||||
switch (action.type) {
|
||||
case "EXPORT_START":
|
||||
return { status: "exporting", error: null };
|
||||
case "EXPORT_SUCCESS":
|
||||
return { status: "success", error: null };
|
||||
case "EXPORT_ERROR":
|
||||
return { status: "error", error: action.error };
|
||||
case "RESET":
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataExport() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const performExport = useCallback(
|
||||
async (mode: ExportMode, format: ExportFormat, password?: string) => {
|
||||
dispatch({ type: "EXPORT_START" });
|
||||
try {
|
||||
const appVersion = await getVersion();
|
||||
|
||||
// Gather data based on mode
|
||||
const data: Record<string, unknown> = {};
|
||||
if (mode === "transactions_with_categories" || mode === "categories_only") {
|
||||
data.categories = await getExportCategories();
|
||||
data.suppliers = await getExportSuppliers();
|
||||
data.keywords = await getExportKeywords();
|
||||
}
|
||||
if (mode === "transactions_with_categories" || mode === "transactions_only") {
|
||||
data.transactions = await getExportTransactions();
|
||||
}
|
||||
|
||||
// Serialize
|
||||
let content: string;
|
||||
let defaultExt: string;
|
||||
if (format === "csv") {
|
||||
content = serializeTransactionsToCsv(data.transactions as never[]);
|
||||
defaultExt = "csv";
|
||||
} else {
|
||||
content = serializeToJson(mode, data, appVersion);
|
||||
defaultExt = "json";
|
||||
}
|
||||
|
||||
// Determine file extension and name
|
||||
const isEncrypted = !!password && password.length > 0;
|
||||
const ext = isEncrypted ? "sref" : defaultExt;
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const defaultName = `simplresult_${mode}_${timestamp}.${ext}`;
|
||||
|
||||
// Build filters
|
||||
const filters: [string, string[]][] = isEncrypted
|
||||
? [["Simpl'Result Encrypted", ["sref"]]]
|
||||
: format === "csv"
|
||||
? [["CSV Files", ["csv"]]]
|
||||
: [["JSON Files", ["json"]]];
|
||||
|
||||
// Pick save location
|
||||
const filePath = await invoke<string | null>("pick_save_file", {
|
||||
defaultName,
|
||||
filters,
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
dispatch({ type: "RESET" });
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
// Write file
|
||||
await invoke("write_export_file", {
|
||||
filePath,
|
||||
content,
|
||||
password: isEncrypted ? password : null,
|
||||
});
|
||||
|
||||
dispatch({ type: "EXPORT_SUCCESS" });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "EXPORT_ERROR",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
|
||||
|
||||
return { state, performExport, reset };
|
||||
}
|
||||
196
src/hooks/useDataImport.ts
Normal file
196
src/hooks/useDataImport.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useReducer, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
parseImportedJson,
|
||||
parseImportedCsv,
|
||||
importCategoriesOnly,
|
||||
importTransactionsWithCategories,
|
||||
importTransactionsOnly,
|
||||
type ExportEnvelope,
|
||||
type ImportSummary,
|
||||
} from "../services/dataExportService";
|
||||
|
||||
type ImportStatus =
|
||||
| "idle"
|
||||
| "reading"
|
||||
| "needsPassword"
|
||||
| "confirming"
|
||||
| "importing"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
interface ImportState {
|
||||
status: ImportStatus;
|
||||
filePath: string | null;
|
||||
summary: ImportSummary | null;
|
||||
parsedData: ExportEnvelope["data"] | null;
|
||||
importType: ExportEnvelope["export_type"] | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ImportAction =
|
||||
| { type: "READ_START" }
|
||||
| { type: "NEEDS_PASSWORD"; filePath: string }
|
||||
| {
|
||||
type: "CONFIRMING";
|
||||
filePath: string;
|
||||
summary: ImportSummary;
|
||||
data: ExportEnvelope["data"];
|
||||
importType: ExportEnvelope["export_type"];
|
||||
}
|
||||
| { type: "IMPORT_START" }
|
||||
| { type: "IMPORT_SUCCESS" }
|
||||
| { type: "IMPORT_ERROR"; error: string }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: ImportState = {
|
||||
status: "idle",
|
||||
filePath: null,
|
||||
summary: null,
|
||||
parsedData: null,
|
||||
importType: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: ImportState, action: ImportAction): ImportState {
|
||||
switch (action.type) {
|
||||
case "READ_START":
|
||||
return { ...initialState, status: "reading" };
|
||||
case "NEEDS_PASSWORD":
|
||||
return { ...initialState, status: "needsPassword", filePath: action.filePath };
|
||||
case "CONFIRMING":
|
||||
return {
|
||||
...state,
|
||||
status: "confirming",
|
||||
filePath: action.filePath,
|
||||
summary: action.summary,
|
||||
parsedData: action.data,
|
||||
importType: action.importType,
|
||||
error: null,
|
||||
};
|
||||
case "IMPORT_START":
|
||||
return { ...state, status: "importing", error: null };
|
||||
case "IMPORT_SUCCESS":
|
||||
return { ...state, status: "success", error: null };
|
||||
case "IMPORT_ERROR":
|
||||
return { ...state, status: "error", error: action.error };
|
||||
case "RESET":
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
|
||||
function parseContent(
|
||||
content: string,
|
||||
filePath: string
|
||||
): { summary: ImportSummary; data: ExportEnvelope["data"]; importType: ExportEnvelope["export_type"] } {
|
||||
const isCsv =
|
||||
filePath.toLowerCase().endsWith(".csv") ||
|
||||
(!filePath.toLowerCase().endsWith(".json") &&
|
||||
!filePath.toLowerCase().endsWith(".sref") &&
|
||||
content.trimStart().charAt(0) !== "{");
|
||||
|
||||
if (isCsv) {
|
||||
const { transactions, summary } = parseImportedCsv(content);
|
||||
return {
|
||||
summary,
|
||||
data: { transactions },
|
||||
importType: "transactions_only",
|
||||
};
|
||||
}
|
||||
|
||||
const { envelope, summary } = parseImportedJson(content);
|
||||
return {
|
||||
summary,
|
||||
data: envelope.data,
|
||||
importType: envelope.export_type,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDataImport() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const pickAndRead = useCallback(async () => {
|
||||
dispatch({ type: "READ_START" });
|
||||
try {
|
||||
const filePath = await invoke<string | null>("pick_import_file", {
|
||||
filters: [["Simpl'Result Files", ["json", "csv", "sref"]]],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
dispatch({ type: "RESET" });
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = await invoke<boolean>("is_file_encrypted", { filePath });
|
||||
|
||||
if (encrypted) {
|
||||
dispatch({ type: "NEEDS_PASSWORD", filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await invoke<string>("read_import_file", {
|
||||
filePath,
|
||||
password: null,
|
||||
});
|
||||
|
||||
const { summary, data, importType } = parseContent(content, filePath);
|
||||
dispatch({ type: "CONFIRMING", filePath, summary, data, importType });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "IMPORT_ERROR",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readWithPassword = useCallback(
|
||||
async (password: string) => {
|
||||
if (!state.filePath) return;
|
||||
dispatch({ type: "READ_START" });
|
||||
try {
|
||||
const content = await invoke<string>("read_import_file", {
|
||||
filePath: state.filePath,
|
||||
password,
|
||||
});
|
||||
|
||||
const { summary, data, importType } = parseContent(content, state.filePath);
|
||||
dispatch({ type: "CONFIRMING", filePath: state.filePath, summary, data, importType });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "IMPORT_ERROR",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[state.filePath]
|
||||
);
|
||||
|
||||
const executeImport = useCallback(async () => {
|
||||
if (!state.parsedData || !state.importType) return;
|
||||
dispatch({ type: "IMPORT_START" });
|
||||
const filename = state.filePath?.split(/[/\\]/).pop() ?? "unknown";
|
||||
try {
|
||||
switch (state.importType) {
|
||||
case "categories_only":
|
||||
await importCategoriesOnly(state.parsedData);
|
||||
break;
|
||||
case "transactions_with_categories":
|
||||
await importTransactionsWithCategories(state.parsedData, filename);
|
||||
break;
|
||||
case "transactions_only":
|
||||
await importTransactionsOnly(state.parsedData, filename);
|
||||
break;
|
||||
}
|
||||
dispatch({ type: "IMPORT_SUCCESS" });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "IMPORT_ERROR",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, [state.parsedData, state.importType, state.filePath]);
|
||||
|
||||
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
|
||||
|
||||
return { state, pickAndRead, readWithPassword, executeImport, reset };
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ImportedFileWithSource } from "../shared/types";
|
||||
import {
|
||||
getAllImportedFiles,
|
||||
|
|
@ -48,7 +47,6 @@ function reducer(
|
|||
export function useImportHistory(onChanged?: () => void) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
|
|
@ -69,11 +67,7 @@ export function useImportHistory(onChanged?: () => void) {
|
|||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (fileId: number, rowCount: number) => {
|
||||
const ok = confirm(
|
||||
t("import.history.deleteConfirm", { count: rowCount })
|
||||
);
|
||||
if (!ok) return;
|
||||
async (fileId: number) => {
|
||||
dispatch({ type: "SET_DELETING", payload: true });
|
||||
try {
|
||||
await deleteImportWithTransactions(fileId);
|
||||
|
|
@ -85,12 +79,10 @@ export function useImportHistory(onChanged?: () => void) {
|
|||
dispatch({ type: "SET_DELETING", payload: false });
|
||||
}
|
||||
},
|
||||
[loadHistory, onChanged, t]
|
||||
[loadHistory, onChanged]
|
||||
);
|
||||
|
||||
const handleDeleteAll = useCallback(async () => {
|
||||
const ok = confirm(t("import.history.deleteAllConfirm"));
|
||||
if (!ok) return;
|
||||
dispatch({ type: "SET_DELETING", payload: true });
|
||||
try {
|
||||
await deleteAllImportsWithTransactions();
|
||||
|
|
@ -101,7 +93,7 @@ export function useImportHistory(onChanged?: () => void) {
|
|||
} finally {
|
||||
dispatch({ type: "SET_DELETING", payload: false });
|
||||
}
|
||||
}, [loadHistory, onChanged, t]);
|
||||
}, [loadHistory, onChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
DuplicateCheckResult,
|
||||
ImportReport,
|
||||
ImportSource,
|
||||
ImportConfigTemplate,
|
||||
ColumnMapping,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
|
|
@ -33,6 +34,12 @@ import {
|
|||
findDuplicates,
|
||||
} from "../services/transactionService";
|
||||
import { categorizeBatch } from "../services/categorizationService";
|
||||
import {
|
||||
getAllTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate as deleteTemplateService,
|
||||
} from "../services/importConfigTemplateService";
|
||||
import { parseDate } from "../utils/dateParser";
|
||||
import { parseFrenchAmount } from "../utils/amountParser";
|
||||
import {
|
||||
|
|
@ -58,6 +65,8 @@ interface WizardState {
|
|||
error: string | null;
|
||||
configuredSourceNames: Set<string>;
|
||||
importedFilesBySource: Map<string, Set<string>>;
|
||||
configTemplates: ImportConfigTemplate[];
|
||||
selectedTemplateId: number | null;
|
||||
}
|
||||
|
||||
type WizardAction =
|
||||
|
|
@ -77,6 +86,8 @@ type WizardAction =
|
|||
| { type: "SET_IMPORT_REPORT"; payload: ImportReport }
|
||||
| { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
|
||||
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
|
||||
| { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
|
||||
| { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null }
|
||||
| { type: "RESET" };
|
||||
|
||||
const defaultConfig: SourceConfig = {
|
||||
|
|
@ -109,6 +120,8 @@ const initialState: WizardState = {
|
|||
error: null,
|
||||
configuredSourceNames: new Set(),
|
||||
importedFilesBySource: new Map(),
|
||||
configTemplates: [],
|
||||
selectedTemplateId: null,
|
||||
};
|
||||
|
||||
function reducer(state: WizardState, action: WizardAction): WizardState {
|
||||
|
|
@ -171,6 +184,10 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
configuredSourceNames: action.payload.names,
|
||||
importedFilesBySource: action.payload.files,
|
||||
};
|
||||
case "SET_CONFIG_TEMPLATES":
|
||||
return { ...state, configTemplates: action.payload };
|
||||
case "SET_SELECTED_TEMPLATE_ID":
|
||||
return { ...state, selectedTemplateId: action.payload };
|
||||
case "RESET":
|
||||
return {
|
||||
...initialState,
|
||||
|
|
@ -178,6 +195,7 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
scannedSources: state.scannedSources,
|
||||
configuredSourceNames: state.configuredSourceNames,
|
||||
importedFilesBySource: state.importedFilesBySource,
|
||||
configTemplates: state.configTemplates,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
|
@ -216,6 +234,9 @@ export function useImportWizard() {
|
|||
}
|
||||
|
||||
dispatch({ type: "SET_CONFIGURED_SOURCES", payload: { names, files } });
|
||||
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, []);
|
||||
|
||||
const scanFolderInternal = useCallback(
|
||||
|
|
@ -261,8 +282,22 @@ export function useImportWizard() {
|
|||
|
||||
const selectSource = useCallback(
|
||||
async (source: ScannedSource) => {
|
||||
dispatch({ type: "SET_SELECTED_SOURCE", payload: source });
|
||||
dispatch({ type: "SET_SELECTED_FILES", payload: source.files });
|
||||
// Sort files: new files first, then already-imported
|
||||
const importedNames = state.importedFilesBySource.get(source.folder_name);
|
||||
const sorted = [...source.files].sort((a, b) => {
|
||||
const aImported = importedNames?.has(a.filename) ?? false;
|
||||
const bImported = importedNames?.has(b.filename) ?? false;
|
||||
if (aImported !== bImported) return aImported ? 1 : -1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
const sortedSource = { ...source, files: sorted };
|
||||
|
||||
// Pre-select only new files
|
||||
const newFiles = sorted.filter((f) => !importedNames?.has(f.filename));
|
||||
|
||||
dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
|
||||
dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });
|
||||
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
|
||||
|
||||
// Check if this source already has config in DB
|
||||
const existing = await getSourceByName(source.folder_name);
|
||||
|
|
@ -328,7 +363,7 @@ export function useImportWizard() {
|
|||
|
||||
dispatch({ type: "SET_STEP", payload: "source-config" });
|
||||
},
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const loadHeadersWithConfig = useCallback(
|
||||
|
|
@ -411,13 +446,116 @@ export function useImportWizard() {
|
|||
|
||||
const selectAllFiles = useCallback(() => {
|
||||
if (state.selectedSource) {
|
||||
const importedNames = state.importedFilesBySource.get(state.selectedSource.folder_name);
|
||||
const newFiles = importedNames
|
||||
? state.selectedSource.files.filter((f) => !importedNames.has(f.filename))
|
||||
: state.selectedSource.files;
|
||||
dispatch({
|
||||
type: "SET_SELECTED_FILES",
|
||||
payload: state.selectedSource.files,
|
||||
payload: newFiles,
|
||||
});
|
||||
}
|
||||
}, [state.selectedSource]);
|
||||
}, [state.selectedSource, state.importedFilesBySource]);
|
||||
|
||||
// Internal helper: parses selected files and returns rows + headers
|
||||
const parseFilesInternal = useCallback(async (): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
|
||||
const config = state.sourceConfig;
|
||||
const allRows: ParsedRow[] = [];
|
||||
let headers: string[] = [];
|
||||
|
||||
for (const file of state.selectedFiles) {
|
||||
const content = await invoke<string>("read_file_content", {
|
||||
filePath: file.file_path,
|
||||
encoding: config.encoding,
|
||||
});
|
||||
|
||||
const preprocessed = preprocessQuotedCSV(content);
|
||||
|
||||
const parsed = Papa.parse(preprocessed, {
|
||||
delimiter: config.delimiter,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
const data = parsed.data as string[][];
|
||||
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
|
||||
|
||||
if (config.hasHeader && data.length > config.skipLines) {
|
||||
headers = data[config.skipLines].map((h) => h.trim());
|
||||
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
|
||||
const firstDataRow = data[config.skipLines];
|
||||
headers = firstDataRow.map((_, i) => `Col ${i}`);
|
||||
}
|
||||
|
||||
for (let i = startIdx; i < data.length; i++) {
|
||||
const raw = data[i];
|
||||
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
|
||||
|
||||
try {
|
||||
const date = parseDate(
|
||||
raw[config.columnMapping.date]?.trim() || "",
|
||||
config.dateFormat
|
||||
);
|
||||
const description =
|
||||
raw[config.columnMapping.description]?.trim() || "";
|
||||
|
||||
let amount: number;
|
||||
if (config.amountMode === "debit_credit") {
|
||||
const debit = parseFrenchAmount(
|
||||
raw[config.columnMapping.debitAmount ?? 0] || ""
|
||||
);
|
||||
const credit = parseFrenchAmount(
|
||||
raw[config.columnMapping.creditAmount ?? 0] || ""
|
||||
);
|
||||
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
|
||||
} else {
|
||||
amount = parseFrenchAmount(
|
||||
raw[config.columnMapping.amount ?? 0] || ""
|
||||
);
|
||||
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
|
||||
amount = -amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (!date) {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Invalid date",
|
||||
sourceFilename: file.filename,
|
||||
});
|
||||
} else if (isNaN(amount)) {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Invalid amount",
|
||||
sourceFilename: file.filename,
|
||||
});
|
||||
} else {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: { date, description, amount },
|
||||
sourceFilename: file.filename,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Parse error",
|
||||
sourceFilename: file.filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { rows: allRows, headers };
|
||||
}, [state.selectedFiles, state.sourceConfig]);
|
||||
|
||||
// Parse files and store preview (does NOT change wizard step)
|
||||
const parsePreview = useCallback(async () => {
|
||||
if (state.selectedFiles.length === 0) return;
|
||||
|
||||
|
|
@ -425,220 +563,160 @@ export function useImportWizard() {
|
|||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const config = state.sourceConfig;
|
||||
const allRows: ParsedRow[] = [];
|
||||
let headers: string[] = [];
|
||||
|
||||
for (const file of state.selectedFiles) {
|
||||
const content = await invoke<string>("read_file_content", {
|
||||
filePath: file.file_path,
|
||||
encoding: config.encoding,
|
||||
});
|
||||
|
||||
const preprocessed = preprocessQuotedCSV(content);
|
||||
|
||||
const parsed = Papa.parse(preprocessed, {
|
||||
delimiter: config.delimiter,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
const data = parsed.data as string[][];
|
||||
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
|
||||
|
||||
if (config.hasHeader && data.length > config.skipLines) {
|
||||
headers = data[config.skipLines].map((h) => h.trim());
|
||||
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
|
||||
const firstDataRow = data[config.skipLines];
|
||||
headers = firstDataRow.map((_, i) => `Col ${i}`);
|
||||
}
|
||||
|
||||
for (let i = startIdx; i < data.length; i++) {
|
||||
const raw = data[i];
|
||||
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
|
||||
|
||||
try {
|
||||
const date = parseDate(
|
||||
raw[config.columnMapping.date]?.trim() || "",
|
||||
config.dateFormat
|
||||
);
|
||||
const description =
|
||||
raw[config.columnMapping.description]?.trim() || "";
|
||||
|
||||
let amount: number;
|
||||
if (config.amountMode === "debit_credit") {
|
||||
const debit = parseFrenchAmount(
|
||||
raw[config.columnMapping.debitAmount ?? 0] || ""
|
||||
);
|
||||
const credit = parseFrenchAmount(
|
||||
raw[config.columnMapping.creditAmount ?? 0] || ""
|
||||
);
|
||||
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
|
||||
} else {
|
||||
amount = parseFrenchAmount(
|
||||
raw[config.columnMapping.amount ?? 0] || ""
|
||||
);
|
||||
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
|
||||
amount = -amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (!date) {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Invalid date",
|
||||
});
|
||||
} else if (isNaN(amount)) {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Invalid amount",
|
||||
});
|
||||
} else {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: { date, description, amount },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
allRows.push({
|
||||
rowIndex: allRows.length,
|
||||
raw,
|
||||
parsed: null,
|
||||
error: "Parse error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await parseFilesInternal();
|
||||
dispatch({
|
||||
type: "SET_PARSED_PREVIEW",
|
||||
payload: { rows: allRows, headers },
|
||||
payload: result,
|
||||
});
|
||||
dispatch({ type: "SET_STEP", payload: "file-preview" });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, [state.selectedFiles, state.sourceConfig]);
|
||||
}, [state.selectedFiles, parseFilesInternal]);
|
||||
|
||||
// Internal helper: runs duplicate checking against parsed rows
|
||||
const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
|
||||
// Save/update source config in DB
|
||||
const config = state.sourceConfig;
|
||||
const mappingJson = JSON.stringify(config.columnMapping);
|
||||
|
||||
let sourceId: number;
|
||||
if (state.existingSource) {
|
||||
sourceId = state.existingSource.id;
|
||||
await updateSource(sourceId, {
|
||||
name: config.name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
column_mapping: mappingJson,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader,
|
||||
});
|
||||
} else {
|
||||
sourceId = await createSource({
|
||||
name: config.name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
column_mapping: mappingJson,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader,
|
||||
});
|
||||
}
|
||||
|
||||
// Check file-level duplicates (check ALL selected files, not just the first)
|
||||
let fileAlreadyImported = false;
|
||||
let existingFileId: number | undefined;
|
||||
|
||||
for (const file of state.selectedFiles) {
|
||||
const hash = await invoke<string>("hash_file", {
|
||||
filePath: file.file_path,
|
||||
});
|
||||
const existing = await existsByHash(hash);
|
||||
if (existing) {
|
||||
fileAlreadyImported = true;
|
||||
existingFileId = existing.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check row-level duplicates against DB
|
||||
const validRows = parsedRows.filter((r) => r.parsed);
|
||||
const duplicateMatches = await findDuplicates(
|
||||
validRows.map((r) => ({
|
||||
date: r.parsed!.date,
|
||||
description: r.parsed!.description,
|
||||
amount: r.parsed!.amount,
|
||||
}))
|
||||
);
|
||||
|
||||
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
|
||||
const duplicateRows = duplicateMatches.map((d) => ({
|
||||
rowIndex: d.rowIndex,
|
||||
date: d.date,
|
||||
description: d.description,
|
||||
amount: d.amount,
|
||||
existingTransactionId: d.existingTransactionId,
|
||||
}));
|
||||
|
||||
// Cross-file duplicate detection: find rows that appear in multiple source files
|
||||
const seenKeys = new Map<string, number>(); // key → first-seen validRows index
|
||||
for (let i = 0; i < validRows.length; i++) {
|
||||
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
|
||||
const row = validRows[i];
|
||||
const key = `${row.parsed!.date}|${row.parsed!.description}|${row.parsed!.amount}`;
|
||||
const firstIdx = seenKeys.get(key);
|
||||
if (firstIdx !== undefined) {
|
||||
// Only flag as cross-file duplicate if rows come from different files
|
||||
if (validRows[firstIdx].sourceFilename !== row.sourceFilename) {
|
||||
duplicateRows.push({
|
||||
rowIndex: i,
|
||||
date: row.parsed!.date,
|
||||
description: row.parsed!.description,
|
||||
amount: row.parsed!.amount,
|
||||
existingTransactionId: -1, // signals "within batch" in the UI
|
||||
});
|
||||
dbDuplicateIndices.add(i);
|
||||
}
|
||||
} else {
|
||||
seenKeys.set(key, i);
|
||||
}
|
||||
}
|
||||
|
||||
const newRows = validRows.filter(
|
||||
(_, i) => !dbDuplicateIndices.has(i)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "SET_DUPLICATE_RESULT",
|
||||
payload: {
|
||||
fileAlreadyImported,
|
||||
existingFileId,
|
||||
duplicateRows,
|
||||
newRows,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
|
||||
}, [state.sourceConfig, state.existingSource, state.selectedFiles]);
|
||||
|
||||
// Check duplicates using already-parsed preview data
|
||||
const checkDuplicates = useCallback(async () => {
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
// Save/update source config in DB
|
||||
const config = state.sourceConfig;
|
||||
const mappingJson = JSON.stringify(config.columnMapping);
|
||||
|
||||
let sourceId: number;
|
||||
if (state.existingSource) {
|
||||
sourceId = state.existingSource.id;
|
||||
await updateSource(sourceId, {
|
||||
name: config.name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
column_mapping: mappingJson,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader,
|
||||
});
|
||||
} else {
|
||||
sourceId = await createSource({
|
||||
name: config.name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
column_mapping: mappingJson,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader,
|
||||
});
|
||||
}
|
||||
|
||||
// Check file-level duplicates
|
||||
let fileAlreadyImported = false;
|
||||
let existingFileId: number | undefined;
|
||||
|
||||
if (state.selectedFiles.length > 0) {
|
||||
const hash = await invoke<string>("hash_file", {
|
||||
filePath: state.selectedFiles[0].file_path,
|
||||
});
|
||||
const existing = await existsByHash(hash);
|
||||
if (existing) {
|
||||
fileAlreadyImported = true;
|
||||
existingFileId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Check row-level duplicates
|
||||
const validRows = state.parsedPreview.filter((r) => r.parsed);
|
||||
const duplicateMatches = await findDuplicates(
|
||||
validRows.map((r) => ({
|
||||
date: r.parsed!.date,
|
||||
description: r.parsed!.description,
|
||||
amount: r.parsed!.amount,
|
||||
}))
|
||||
);
|
||||
|
||||
// Detect intra-batch duplicates (rows that appear more than once within the import)
|
||||
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
|
||||
const seenKeys = new Set<string>();
|
||||
const batchDuplicateIndices = new Set<number>();
|
||||
|
||||
for (let i = 0; i < validRows.length; i++) {
|
||||
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
|
||||
const r = validRows[i].parsed!;
|
||||
const key = `${r.date}|${r.description}|${r.amount}`;
|
||||
if (seenKeys.has(key)) {
|
||||
batchDuplicateIndices.add(i);
|
||||
} else {
|
||||
seenKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateIndices = new Set([...dbDuplicateIndices, ...batchDuplicateIndices]);
|
||||
const newRows = validRows.filter(
|
||||
(_, i) => !duplicateIndices.has(i)
|
||||
);
|
||||
const duplicateRows = [
|
||||
...duplicateMatches.map((d) => ({
|
||||
rowIndex: d.rowIndex,
|
||||
date: d.date,
|
||||
description: d.description,
|
||||
amount: d.amount,
|
||||
existingTransactionId: d.existingTransactionId,
|
||||
})),
|
||||
...[...batchDuplicateIndices].map((i) => ({
|
||||
rowIndex: i,
|
||||
date: validRows[i].parsed!.date,
|
||||
description: validRows[i].parsed!.description,
|
||||
amount: validRows[i].parsed!.amount,
|
||||
existingTransactionId: -1,
|
||||
})),
|
||||
];
|
||||
|
||||
dispatch({
|
||||
type: "SET_DUPLICATE_RESULT",
|
||||
payload: {
|
||||
fileAlreadyImported,
|
||||
existingFileId,
|
||||
duplicateRows,
|
||||
newRows,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
|
||||
await checkDuplicatesInternal(state.parsedPreview);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, [state.sourceConfig, state.existingSource, state.selectedFiles, state.parsedPreview]);
|
||||
}, [state.parsedPreview, checkDuplicatesInternal]);
|
||||
|
||||
// Parse files then check duplicates in one step (skips preview step)
|
||||
const parseAndCheckDuplicates = useCallback(async () => {
|
||||
if (state.selectedFiles.length === 0) return;
|
||||
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const result = await parseFilesInternal();
|
||||
dispatch({
|
||||
type: "SET_PARSED_PREVIEW",
|
||||
payload: result,
|
||||
});
|
||||
await checkDuplicatesInternal(result.rows);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, [state.selectedFiles, parseFilesInternal, checkDuplicatesInternal]);
|
||||
|
||||
const executeImport = useCallback(async () => {
|
||||
if (!state.duplicateResult) return;
|
||||
|
|
@ -674,22 +752,23 @@ export function useImportWizard() {
|
|||
payload: { current: 0, total: totalRows, file: state.selectedFiles[0]?.filename || "" },
|
||||
});
|
||||
|
||||
// Create imported file record
|
||||
let fileHash = "";
|
||||
if (state.selectedFiles.length > 0) {
|
||||
fileHash = await invoke<string>("hash_file", {
|
||||
filePath: state.selectedFiles[0].file_path,
|
||||
// Create one imported_files record per file
|
||||
const fileIdMap = new Map<string, number>();
|
||||
for (const file of state.selectedFiles) {
|
||||
const hash = await invoke<string>("hash_file", {
|
||||
filePath: file.file_path,
|
||||
});
|
||||
const rowCount = validRows.filter((r) => r.sourceFilename === file.filename).length;
|
||||
const fId = await createImportedFile({
|
||||
source_id: sourceId,
|
||||
filename: file.filename,
|
||||
file_hash: hash,
|
||||
row_count: rowCount,
|
||||
status: "completed",
|
||||
});
|
||||
fileIdMap.set(file.filename, fId);
|
||||
}
|
||||
|
||||
const fileId = await createImportedFile({
|
||||
source_id: sourceId,
|
||||
filename: state.selectedFiles.map((f) => f.filename).join(", "),
|
||||
file_hash: fileHash,
|
||||
row_count: totalRows,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Auto-categorize
|
||||
const descriptions = validRows.map((r) => r.parsed!.description);
|
||||
const categorizations = await categorizeBatch(descriptions);
|
||||
|
|
@ -711,7 +790,7 @@ export function useImportWizard() {
|
|||
description: row.parsed!.description,
|
||||
amount: row.parsed!.amount,
|
||||
source_id: sourceId,
|
||||
file_id: fileId,
|
||||
file_id: fileIdMap.get(row.sourceFilename || "") ?? 0,
|
||||
original_description: row.raw.join(config.delimiter),
|
||||
category_id: cat.category_id,
|
||||
supplier_id: cat.supplier_id,
|
||||
|
|
@ -722,9 +801,10 @@ export function useImportWizard() {
|
|||
let importedCount = 0;
|
||||
try {
|
||||
importedCount = await insertBatch(transactions, (inserted) => {
|
||||
const currentFile = validRows[inserted - 1]?.sourceFilename || "";
|
||||
dispatch({
|
||||
type: "SET_IMPORT_PROGRESS",
|
||||
payload: { current: inserted, total: totalRows, file: state.selectedFiles[0]?.filename || "" },
|
||||
payload: { current: inserted, total: totalRows, file: currentFile },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -733,7 +813,10 @@ export function useImportWizard() {
|
|||
payload: { current: importedCount, total: totalRows, file: "done" },
|
||||
});
|
||||
} catch (e) {
|
||||
await updateFileStatus(fileId, "error", 0, String(e));
|
||||
// Update status on all file records on error
|
||||
for (const fId of fileIdMap.values()) {
|
||||
await updateFileStatus(fId, "error", 0, String(e));
|
||||
}
|
||||
errors.push({
|
||||
rowIndex: 0,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
|
|
@ -797,8 +880,7 @@ export function useImportWizard() {
|
|||
encoding: state.sourceConfig.encoding,
|
||||
});
|
||||
|
||||
const preprocessed = preprocessQuotedCSV(content);
|
||||
const result = runAutoDetect(preprocessed);
|
||||
const result = runAutoDetect(content);
|
||||
|
||||
if (result) {
|
||||
const newConfig = {
|
||||
|
|
@ -836,6 +918,82 @@ export function useImportWizard() {
|
|||
}
|
||||
}, [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]);
|
||||
|
||||
const saveConfigAsTemplate = useCallback(async (name: string) => {
|
||||
const config = state.sourceConfig;
|
||||
await createTemplate({
|
||||
name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader ? 1 : 0,
|
||||
column_mapping: JSON.stringify(config.columnMapping),
|
||||
amount_mode: config.amountMode,
|
||||
sign_convention: config.signConvention,
|
||||
});
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, [state.sourceConfig]);
|
||||
|
||||
const applyConfigTemplate = useCallback((templateId: number) => {
|
||||
const template = state.configTemplates.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
const mapping = JSON.parse(template.column_mapping) as ColumnMapping;
|
||||
const newConfig: SourceConfig = {
|
||||
name: state.sourceConfig.name,
|
||||
delimiter: template.delimiter,
|
||||
encoding: template.encoding,
|
||||
dateFormat: template.date_format,
|
||||
skipLines: template.skip_lines,
|
||||
columnMapping: mapping,
|
||||
amountMode: template.amount_mode,
|
||||
signConvention: template.sign_convention,
|
||||
hasHeader: !!template.has_header,
|
||||
};
|
||||
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
|
||||
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId });
|
||||
|
||||
// Reload headers with new config
|
||||
if (state.selectedFiles.length > 0) {
|
||||
loadHeadersWithConfig(
|
||||
state.selectedFiles[0].file_path,
|
||||
newConfig.delimiter,
|
||||
newConfig.encoding,
|
||||
newConfig.skipLines,
|
||||
newConfig.hasHeader
|
||||
);
|
||||
}
|
||||
}, [state.configTemplates, state.sourceConfig.name, state.selectedFiles, loadHeadersWithConfig]);
|
||||
|
||||
const updateConfigTemplate = useCallback(async () => {
|
||||
if (!state.selectedTemplateId) return;
|
||||
const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId);
|
||||
if (!template) return;
|
||||
const config = state.sourceConfig;
|
||||
await updateTemplate(state.selectedTemplateId, {
|
||||
name: template.name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader ? 1 : 0,
|
||||
column_mapping: JSON.stringify(config.columnMapping),
|
||||
amount_mode: config.amountMode,
|
||||
sign_convention: config.signConvention,
|
||||
});
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, [state.selectedTemplateId, state.configTemplates, state.sourceConfig]);
|
||||
|
||||
const deleteConfigTemplate = useCallback(async (id: number) => {
|
||||
await deleteTemplateService(id);
|
||||
if (state.selectedTemplateId === id) {
|
||||
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
|
||||
}
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, [state.selectedTemplateId]);
|
||||
|
||||
return {
|
||||
state,
|
||||
browseFolder,
|
||||
|
|
@ -846,10 +1004,15 @@ export function useImportWizard() {
|
|||
selectAllFiles,
|
||||
parsePreview,
|
||||
checkDuplicates,
|
||||
parseAndCheckDuplicates,
|
||||
executeImport,
|
||||
goToStep,
|
||||
reset,
|
||||
autoDetectConfig,
|
||||
saveConfigAsTemplate,
|
||||
applyConfigTemplate,
|
||||
updateConfigTemplate,
|
||||
deleteConfigTemplate,
|
||||
toggleDuplicateRow: (index: number) =>
|
||||
dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }),
|
||||
setSkipAllDuplicates: (skipAll: boolean) =>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,29 @@ import type {
|
|||
MonthlyTrendItem,
|
||||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
PivotConfig,
|
||||
PivotResult,
|
||||
} from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||
import { getExpensesByCategory } from "../services/dashboardService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
||||
interface ReportsState {
|
||||
tab: ReportTab;
|
||||
period: DashboardPeriod;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
sourceId: number | null;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categorySpending: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
pivotConfig: PivotConfig;
|
||||
pivotResult: PivotResult;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -26,14 +39,32 @@ type ReportsAction =
|
|||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||
| { 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_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 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 = {
|
||||
tab: "trends",
|
||||
period: "6months",
|
||||
customDateFrom: monthStartStr,
|
||||
customDateTo: todayStr,
|
||||
sourceId: null,
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {} },
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||
budgetVsActual: [],
|
||||
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -54,73 +85,80 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
|||
return { ...state, categorySpending: action.payload, isLoading: false };
|
||||
case "SET_CATEGORY_OVER_TIME":
|
||||
return { ...state, categoryOverTime: action.payload, isLoading: false };
|
||||
case "SET_BUDGET_MONTH":
|
||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||
case "SET_BUDGET_VS_ACTUAL":
|
||||
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:
|
||||
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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => {
|
||||
const fetchData = useCallback(async (
|
||||
tab: ReportTab,
|
||||
period: DashboardPeriod,
|
||||
budgetYear: number,
|
||||
budgetMonth: number,
|
||||
customFrom?: string,
|
||||
customTo?: string,
|
||||
pivotCfg?: PivotConfig,
|
||||
srcId?: number | null,
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const { dateFrom, dateTo } = computeDateRange(period);
|
||||
|
||||
switch (tab) {
|
||||
case "trends": {
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||
break;
|
||||
}
|
||||
case "byCategory": {
|
||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||
break;
|
||||
}
|
||||
case "overTime": {
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
break;
|
||||
}
|
||||
case "budgetVsActual": {
|
||||
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||
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) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
|
|
@ -132,8 +170,8 @@ export function useReports() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period);
|
||||
}, [state.tab, state.period, fetchData]);
|
||||
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, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
|
|
@ -143,5 +181,21 @@ export function useReports() {
|
|||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod };
|
||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TransactionPageResult,
|
||||
Category,
|
||||
ImportSource,
|
||||
SplitChild,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
getTransactionPage,
|
||||
|
|
@ -14,6 +15,9 @@ import {
|
|||
getAllCategories,
|
||||
getAllImportSources,
|
||||
autoCategorizeTransactions,
|
||||
getSplitChildren,
|
||||
saveSplitAdjustment,
|
||||
deleteSplitAdjustment,
|
||||
} from "../services/transactionService";
|
||||
import { createKeyword } from "../services/categoryService";
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ const initialFilters: TransactionFilters = {
|
|||
search: "",
|
||||
categoryId: null,
|
||||
sourceId: null,
|
||||
dateFrom: null,
|
||||
dateFrom: `${new Date().getFullYear()}-01-01`,
|
||||
dateTo: null,
|
||||
uncategorizedOnly: false,
|
||||
};
|
||||
|
|
@ -308,6 +312,54 @@ export function useTransactions() {
|
|||
[]
|
||||
);
|
||||
|
||||
const loadSplitChildren = useCallback(
|
||||
async (parentId: number): Promise<SplitChild[]> => {
|
||||
try {
|
||||
return await getSplitChildren(parentId);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const saveSplit = useCallback(
|
||||
async (
|
||||
parentId: number,
|
||||
entries: Array<{ category_id: number; amount: number; description: string }>
|
||||
) => {
|
||||
try {
|
||||
await saveSplitAdjustment(parentId, entries);
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[state.sort, state.page, state.pageSize, fetchData]
|
||||
);
|
||||
|
||||
const deleteSplit = useCallback(
|
||||
async (parentId: number) => {
|
||||
try {
|
||||
await deleteSplitAdjustment(parentId);
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[state.sort, state.page, state.pageSize, fetchData]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
setFilter,
|
||||
|
|
@ -317,5 +369,8 @@ export function useTransactions() {
|
|||
saveNotes,
|
||||
autoCategorize,
|
||||
addKeywordToCategory,
|
||||
loadSplitChildren,
|
||||
saveSplit,
|
||||
deleteSplit,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type UpdateStatus =
|
|||
interface UpdaterState {
|
||||
status: UpdateStatus;
|
||||
version: string | null;
|
||||
body: string | null;
|
||||
progress: number;
|
||||
contentLength: number | null;
|
||||
error: string | null;
|
||||
|
|
@ -23,7 +24,7 @@ interface UpdaterState {
|
|||
type UpdaterAction =
|
||||
| { type: "CHECK_START" }
|
||||
| { type: "UP_TO_DATE" }
|
||||
| { type: "AVAILABLE"; version: string }
|
||||
| { type: "AVAILABLE"; version: string; body: string | null }
|
||||
| { type: "DOWNLOAD_START" }
|
||||
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
||||
| { type: "READY_TO_INSTALL" }
|
||||
|
|
@ -33,6 +34,7 @@ type UpdaterAction =
|
|||
const initialState: UpdaterState = {
|
||||
status: "idle",
|
||||
version: null,
|
||||
body: null,
|
||||
progress: 0,
|
||||
contentLength: null,
|
||||
error: null,
|
||||
|
|
@ -45,7 +47,7 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
|||
case "UP_TO_DATE":
|
||||
return { ...state, status: "upToDate", error: null };
|
||||
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":
|
||||
return { ...state, status: "downloading", progress: 0, contentLength: null, error: null };
|
||||
case "DOWNLOAD_PROGRESS":
|
||||
|
|
@ -69,7 +71,7 @@ export function useUpdater() {
|
|||
const update = await check();
|
||||
if (update) {
|
||||
updateRef.current = update;
|
||||
dispatch({ type: "AVAILABLE", version: update.version });
|
||||
dispatch({ type: "AVAILABLE", version: update.version, body: update.body ?? null });
|
||||
} else {
|
||||
dispatch({ type: "UP_TO_DATE" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"app": {
|
||||
"name": "Simpl'Result"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Version History",
|
||||
"description": "View what's new and fixed in each version",
|
||||
"empty": "No entries available"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"import": "Import",
|
||||
|
|
@ -17,16 +22,24 @@
|
|||
"balance": "Balance",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses",
|
||||
"net": "Net",
|
||||
"noData": "No data available. Start by importing your bank statements.",
|
||||
"expensesByCategory": "Expenses by Category",
|
||||
"recentTransactions": "Recent Transactions",
|
||||
"budgetVsActual": "Budget vs Actual",
|
||||
"expensesOverTime": "Expenses Over Time",
|
||||
"period": {
|
||||
"month": "This month",
|
||||
"3months": "3 months",
|
||||
"6months": "6 months",
|
||||
"12months": "12 months",
|
||||
"all": "All"
|
||||
"year": "This year",
|
||||
"all": "All",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"dateFrom": "From",
|
||||
"dateTo": "To",
|
||||
"apply": "Apply",
|
||||
"help": {
|
||||
"title": "How to use the Dashboard",
|
||||
"tips": [
|
||||
|
|
@ -83,7 +96,15 @@
|
|||
"creditColumn": "Credit column",
|
||||
"selectFiles": "Files to import",
|
||||
"selectAll": "Select all",
|
||||
"autoDetect": "Auto-detect"
|
||||
"alreadyImported": "Imported",
|
||||
"autoDetect": "Auto-detect",
|
||||
"saveAsTemplate": "Save as template",
|
||||
"loadTemplate": "Load template",
|
||||
"templateName": "Template name",
|
||||
"templateSaved": "Template saved",
|
||||
"deleteTemplate": "Delete template",
|
||||
"noTemplates": "No templates saved",
|
||||
"updateTemplate": "Update template"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Data Preview",
|
||||
|
|
@ -207,6 +228,16 @@
|
|||
"addKeyword": "Add keyword",
|
||||
"keywordAdded": "Keyword added",
|
||||
"keywordPlaceholder": "Keyword to match...",
|
||||
"splitAdjustment": "Split adjustment",
|
||||
"splitBase": "Base",
|
||||
"splitAdjusted": "Adjusted",
|
||||
"splitCategory": "Category",
|
||||
"splitAmount": "Amount",
|
||||
"splitDescription": "Description",
|
||||
"splitAddRow": "Add split",
|
||||
"splitRemove": "Remove split",
|
||||
"splitTotal": "Total must equal original amount",
|
||||
"splitDeleteConfirm": "Remove this split adjustment?",
|
||||
"help": {
|
||||
"title": "How to use Transactions",
|
||||
"tips": [
|
||||
|
|
@ -235,7 +266,9 @@
|
|||
"reinitialize": "Re-initialize",
|
||||
"reinitializeConfirm": "Reset all categories and keywords to their default values? Transaction categories will be unlinked. This cannot be undone.",
|
||||
"noParent": "No parent (top-level)",
|
||||
"sortOrder": "Sort Order",
|
||||
"isInputable": "Allow input",
|
||||
"isInputableHint": "Uncheck to hide from budget and transaction dropdowns",
|
||||
"dragToReorder": "Drag to reorder or change parent",
|
||||
"selectCategory": "Select a category to view details",
|
||||
"keywordCount": "Keywords",
|
||||
"keywordText": "Keyword...",
|
||||
|
|
@ -269,6 +302,7 @@
|
|||
"selectAdjustment": "Select an adjustment",
|
||||
"category": "Category",
|
||||
"noEntries": "No entries yet",
|
||||
"splitTransactions": "Transaction splits",
|
||||
"help": {
|
||||
"title": "How to use Adjustments",
|
||||
"tips": [
|
||||
|
|
@ -284,9 +318,19 @@
|
|||
"planned": "Planned",
|
||||
"actual": "Actual",
|
||||
"difference": "Difference",
|
||||
"annual": "Annual",
|
||||
"previousYear": "Prev. Year",
|
||||
"splitEvenly": "Split evenly across 12 months",
|
||||
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
||||
"clickToEdit": "Click to edit",
|
||||
"applyToMonth": "Apply to month",
|
||||
"allMonths": "All 12 months",
|
||||
"expenses": "Expenses",
|
||||
"income": "Income",
|
||||
"transfers": "Transfers",
|
||||
"totalExpenses": "Total Expenses",
|
||||
"totalIncome": "Total Income",
|
||||
"totalTransfers": "Total Transfers",
|
||||
"totalPlanned": "Total Planned",
|
||||
"totalActual": "Total Actual",
|
||||
"totalDifference": "Difference",
|
||||
|
|
@ -296,15 +340,16 @@
|
|||
"noTemplates": "No templates saved yet.",
|
||||
"templateName": "Template name",
|
||||
"templateDescription": "Description (optional)",
|
||||
"directSuffix": "(direct)",
|
||||
"deleteTemplateConfirm": "Delete this template?",
|
||||
"help": {
|
||||
"title": "How to use Budget",
|
||||
"tips": [
|
||||
"Use the month navigator to switch between months",
|
||||
"Click on a planned amount to edit it inline — press Enter to save or Escape to cancel",
|
||||
"The actual column shows real spending from your imported transactions",
|
||||
"Green means under budget, red means over budget",
|
||||
"Save your budget as a template and apply it to other months quickly"
|
||||
"Use the year navigator to switch between years",
|
||||
"Click on any month cell to edit the planned amount — press Enter to save, Escape to cancel, Tab to move to next month",
|
||||
"The Annual column shows the total of all 12 months",
|
||||
"Use the split button to distribute the annual total evenly across all months",
|
||||
"Save your budget as a template and apply it to specific months or all 12 at once"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -314,14 +359,63 @@
|
|||
"byCategory": "Expenses by Category",
|
||||
"overTime": "Category Over Time",
|
||||
"trends": "Monthly Trends",
|
||||
"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": {
|
||||
"monthly": "Monthly",
|
||||
"ytd": "Year-to-Date",
|
||||
"dollarVar": "$ Var",
|
||||
"pctVar": "% Var",
|
||||
"noData": "No budget or transaction data for this period.",
|
||||
"titlePrefix": "Budget vs Actual for"
|
||||
},
|
||||
"dynamic": "Dynamic Report",
|
||||
"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": {
|
||||
"title": "How to use Reports",
|
||||
"tips": [
|
||||
"Switch between Trends, By Category, and Over Time views using the tabs",
|
||||
"Use the period selector to adjust the time range for all charts",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -340,7 +434,63 @@
|
|||
"installButton": "Install and restart",
|
||||
"installing": "Installing...",
|
||||
"error": "Update failed",
|
||||
"retryButton": "Retry"
|
||||
"retryButton": "Retry",
|
||||
"releaseNotes": "What's New"
|
||||
},
|
||||
"dataManagement": {
|
||||
"title": "Data Management",
|
||||
"export": {
|
||||
"title": "Export",
|
||||
"modeLabel": "What to export",
|
||||
"modeTransactionsWithCategories": "Transactions with categories",
|
||||
"modeTransactionsOnly": "Transactions only",
|
||||
"modeCategoriesOnly": "Categories only",
|
||||
"formatLabel": "Format",
|
||||
"csvDisabledNote": "transactions only",
|
||||
"encryptLabel": "Encrypt with password",
|
||||
"passwordPlaceholder": "Password (min 8 characters)",
|
||||
"passwordConfirmPlaceholder": "Confirm password",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"button": "Export",
|
||||
"success": "Export completed successfully"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"description": "Import data from a previously exported file. This will replace existing data.",
|
||||
"button": "Import from file",
|
||||
"passwordRequired": "This file is encrypted. Enter the password to decrypt it.",
|
||||
"passwordPlaceholder": "Password",
|
||||
"decrypt": "Decrypt",
|
||||
"confirmTitle": "Replace Data",
|
||||
"willDeleteLabel": "The following data will be deleted:",
|
||||
"willDeleteCategories": "All categories, suppliers, and keywords",
|
||||
"willDeleteTransactions": "All transactions and import history",
|
||||
"willDeleteAll": "All transactions, categories, suppliers, keywords, and import history",
|
||||
"willImportLabel": "The following data will be imported:",
|
||||
"countCategories": "{{count}} category(ies)",
|
||||
"countSuppliers": "{{count}} supplier(s)",
|
||||
"countKeywords": "{{count}} keyword(s)",
|
||||
"countTransactions": "{{count}} transaction(s)",
|
||||
"irreversibleWarning": "This action is irreversible. All existing data of the selected type will be permanently deleted and replaced.",
|
||||
"typeToConfirm": "Type \"{{word}}\" to confirm:",
|
||||
"confirmWord": "REPLACE",
|
||||
"replaceButton": "Replace Data",
|
||||
"success": "Import completed successfully",
|
||||
"tryAgain": "Try again"
|
||||
}
|
||||
},
|
||||
"userGuide": {
|
||||
"title": "User Guide",
|
||||
"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.",
|
||||
"help": {
|
||||
|
|
@ -352,6 +502,334 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"hideCategory": "Hide category",
|
||||
"viewTransactions": "View transactions",
|
||||
"hiddenCategories": "Hidden",
|
||||
"showAll": "Show all",
|
||||
"total": "Total",
|
||||
"transactions": "transactions",
|
||||
"clickToShow": "Click to show"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Apr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dec"
|
||||
},
|
||||
"docs": {
|
||||
"title": "User Guide",
|
||||
"backToSettings": "Back to Settings",
|
||||
"print": "Print",
|
||||
"features": "Features",
|
||||
"howTo": "How To",
|
||||
"quickStart": "Quick Start",
|
||||
"tipsHeader": "Tips",
|
||||
"gettingStarted": {
|
||||
"title": "Getting Started",
|
||||
"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": [
|
||||
"Import CSV bank statements from multiple sources",
|
||||
"Automatic and manual transaction categorization",
|
||||
"Split a transaction across multiple categories",
|
||||
"Budget planning with monthly and annual views",
|
||||
"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": [
|
||||
"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",
|
||||
"Place your CSV bank statements in the corresponding subfolder",
|
||||
"Open the Import page and configure your source (column mapping, delimiter, date format)",
|
||||
"Import your transactions, then head to Categories to set up keyword rules",
|
||||
"Use Auto-categorize on the Transactions page to apply your rules in bulk",
|
||||
"Set up your Budget and track progress via Reports"
|
||||
],
|
||||
"tips": [
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Dashboard",
|
||||
"overview": "The Dashboard gives you an at-a-glance summary of your financial situation for a selected time period.",
|
||||
"features": [
|
||||
"Balance, income, and expense summary cards",
|
||||
"Expense breakdown by category (pie chart with SVG patterns)",
|
||||
"Budget vs Actual table for the current month (variance in $ and %)",
|
||||
"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": [
|
||||
"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",
|
||||
"Check the pie chart to see how your spending is distributed across categories",
|
||||
"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": [
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Import",
|
||||
"overview": "Import bank statements from CSV files using a step-by-step wizard. Each bank account is represented as a source folder.",
|
||||
"features": [
|
||||
"Multi-step import wizard with data preview",
|
||||
"Configurable column mapping, delimiter, and date format",
|
||||
"Automatic duplicate detection (within batch and against existing data)",
|
||||
"Import templates to save and reuse source configurations",
|
||||
"Import history with the ability to delete past imports"
|
||||
],
|
||||
"steps": [
|
||||
"Set your import folder via the folder picker at the top of the page",
|
||||
"Create a subfolder for each bank/source and place CSV files inside",
|
||||
"Click on a source to open the import wizard",
|
||||
"Configure the delimiter, encoding, date format, and column mapping",
|
||||
"Select which files to import and preview the parsed data",
|
||||
"Check for duplicates, review the summary, then confirm the import"
|
||||
],
|
||||
"tips": [
|
||||
"Save your configuration as a template so you don't have to reconfigure each time",
|
||||
"Files already imported are marked with a badge — re-importing them will trigger duplicate detection",
|
||||
"You can delete an import from the history to remove all its transactions"
|
||||
]
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transactions",
|
||||
"overview": "Browse, filter, sort, and categorize all your imported transactions. This is where you organize your financial data.",
|
||||
"features": [
|
||||
"Search and filter by description, category, source, or date range",
|
||||
"Quick period selectors (this month, last month, this year, etc.)",
|
||||
"Sortable columns (date, description, amount, category)",
|
||||
"Inline category assignment via dropdown",
|
||||
"Auto-categorize based on keyword rules",
|
||||
"Add keywords directly from a transaction",
|
||||
"Split a transaction across multiple categories",
|
||||
"Transaction notes"
|
||||
],
|
||||
"steps": [
|
||||
"Use the filter bar to narrow down transactions by text, category, source, or date",
|
||||
"Click a column header to sort ascending or descending",
|
||||
"To categorize a transaction, click its category dropdown and select a category",
|
||||
"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 split a transaction across categories, use the Split button and add amounts per category"
|
||||
],
|
||||
"tips": [
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Categories",
|
||||
"overview": "Manage your category tree with subcategories, keyword rules for auto-categorization, and custom colors.",
|
||||
"features": [
|
||||
"Hierarchical categories with parent/child relationships",
|
||||
"Three category types: Expense, Income, Transfer",
|
||||
"Keyword rules with priority levels for auto-categorization",
|
||||
"Custom colors for chart display",
|
||||
"Drag-and-drop to reorder categories or change their parent",
|
||||
"Toggle categories as inputable or non-inputable",
|
||||
"All Keywords view to see all rules at a glance",
|
||||
"Re-initialize categories to defaults"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"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",
|
||||
"Set keyword priority to resolve conflicts when multiple categories match",
|
||||
"Use the color picker to assign a custom color for charts"
|
||||
],
|
||||
"tips": [
|
||||
"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",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"adjustments": {
|
||||
"title": "Adjustments",
|
||||
"overview": "Add manual entries that don't come from bank imports, and view transaction splits created from the Transactions page.",
|
||||
"features": [
|
||||
"Create named adjustment groups with multiple entries",
|
||||
"Assign a category to each entry",
|
||||
"Mark adjustments as recurring",
|
||||
"View transaction splits in a dedicated section"
|
||||
],
|
||||
"steps": [
|
||||
"Click New Adjustment to create an adjustment group",
|
||||
"Add entries with a description, amount, date, and category",
|
||||
"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": [
|
||||
"Adjustments appear in your budget actuals alongside imported transactions",
|
||||
"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": {
|
||||
"title": "Budget",
|
||||
"overview": "Plan your monthly budget for each category and track planned vs. actual spending throughout the year.",
|
||||
"features": [
|
||||
"Monthly budget grid for all categories",
|
||||
"Annual column with automatic totals",
|
||||
"Split annual amount evenly across 12 months",
|
||||
"Budget templates to save and apply configurations",
|
||||
"Parent category subtotals",
|
||||
"Column headers stay fixed when scrolling vertically"
|
||||
],
|
||||
"steps": [
|
||||
"Use the year navigator to select the budget year",
|
||||
"Click on any month cell to enter a planned amount",
|
||||
"Press Enter to save, Escape to cancel, or Tab to move to the next month",
|
||||
"Use the split button (on the Annual column) to distribute evenly across all months",
|
||||
"Save your budget as a template to reuse it in future years"
|
||||
],
|
||||
"tips": [
|
||||
"The Annual column auto-sums all 12 months — a warning appears if monthly totals don't match",
|
||||
"Templates can be applied to specific months or all 12 at once",
|
||||
"Parent categories show subtotals aggregated from their children"
|
||||
]
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"overview": "Visualize your financial data with interactive charts and compare your budget plan against actual spending.",
|
||||
"features": [
|
||||
"Monthly Trends: income vs. expenses over time (bar chart)",
|
||||
"Expenses by Category: spending breakdown (pie chart)",
|
||||
"Category Over Time: track how each category evolves (line chart)",
|
||||
"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": [
|
||||
"Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
|
||||
"Adjust the time period using the period selector",
|
||||
"Right-click a category in any chart to hide it or view its transaction details",
|
||||
"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": [
|
||||
"Hidden categories are remembered while you stay on the page — click Show All to reset",
|
||||
"The period selector applies to all chart tabs simultaneously",
|
||||
"Budget vs Actual shows dollar and percentage variance for each category",
|
||||
"SVG patterns help colorblind users distinguish categories in charts"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"overview": "Configure app preferences, check for updates, access the user guide, and manage your data with export/import tools.",
|
||||
"features": [
|
||||
"App version display",
|
||||
"Complete user guide accessible directly from settings",
|
||||
"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 import from a previously exported file",
|
||||
"Optional AES-256-GCM encryption for exported files"
|
||||
],
|
||||
"steps": [
|
||||
"Click User Guide to access the full documentation",
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"tips": [
|
||||
"Updates only replace the app binary — your database is never modified",
|
||||
"Change the app language using the language selector in the sidebar",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profiles",
|
||||
"select": "Select a profile",
|
||||
"create": "Create Profile",
|
||||
"edit": "Edit Profile",
|
||||
"delete": "Delete Profile",
|
||||
"deleteConfirm": "Delete this profile and all its data? This cannot be undone.",
|
||||
"name": "Profile Name",
|
||||
"namePlaceholder": "Enter a name...",
|
||||
"color": "Color",
|
||||
"pin": "PIN",
|
||||
"pinSet": "PIN set",
|
||||
"pinNotSet": "No PIN",
|
||||
"setPin": "Set PIN",
|
||||
"removePin": "Remove PIN",
|
||||
"enterPin": "Enter your PIN",
|
||||
"wrongPin": "Wrong PIN. Try again.",
|
||||
"switchProfile": "Switch Profile",
|
||||
"manageProfiles": "Manage Profiles",
|
||||
"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": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"app": {
|
||||
"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": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"import": "Importer",
|
||||
|
|
@ -17,16 +22,24 @@
|
|||
"balance": "Solde",
|
||||
"income": "Revenus",
|
||||
"expenses": "Dépenses",
|
||||
"net": "Net",
|
||||
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
|
||||
"expensesByCategory": "Dépenses par catégorie",
|
||||
"recentTransactions": "Transactions récentes",
|
||||
"budgetVsActual": "Budget vs Réel",
|
||||
"expensesOverTime": "Dépenses dans le temps",
|
||||
"period": {
|
||||
"month": "Ce mois",
|
||||
"3months": "3 mois",
|
||||
"6months": "6 mois",
|
||||
"12months": "12 mois",
|
||||
"all": "Tout"
|
||||
"year": "Cette année",
|
||||
"all": "Tout",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"dateFrom": "Du",
|
||||
"dateTo": "Au",
|
||||
"apply": "Appliquer",
|
||||
"help": {
|
||||
"title": "Comment utiliser le tableau de bord",
|
||||
"tips": [
|
||||
|
|
@ -83,7 +96,15 @@
|
|||
"creditColumn": "Colonne crédit",
|
||||
"selectFiles": "Fichiers à importer",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"autoDetect": "Auto-détecter"
|
||||
"alreadyImported": "Importé",
|
||||
"autoDetect": "Auto-détecter",
|
||||
"saveAsTemplate": "Sauver comme modèle",
|
||||
"loadTemplate": "Charger un modèle",
|
||||
"templateName": "Nom du modèle",
|
||||
"templateSaved": "Modèle sauvegardé",
|
||||
"deleteTemplate": "Supprimer le modèle",
|
||||
"noTemplates": "Aucun modèle sauvegardé",
|
||||
"updateTemplate": "Mettre à jour le modèle"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Aperçu des données",
|
||||
|
|
@ -207,6 +228,16 @@
|
|||
"addKeyword": "Ajouter un mot-clé",
|
||||
"keywordAdded": "Mot-clé ajouté",
|
||||
"keywordPlaceholder": "Mot-clé à rechercher...",
|
||||
"splitAdjustment": "Répartition",
|
||||
"splitBase": "Base",
|
||||
"splitAdjusted": "Ajusté",
|
||||
"splitCategory": "Catégorie",
|
||||
"splitAmount": "Montant",
|
||||
"splitDescription": "Description",
|
||||
"splitAddRow": "Ajouter une répartition",
|
||||
"splitRemove": "Supprimer la répartition",
|
||||
"splitTotal": "Le total doit égaler le montant original",
|
||||
"splitDeleteConfirm": "Supprimer cette répartition ?",
|
||||
"help": {
|
||||
"title": "Comment utiliser les Transactions",
|
||||
"tips": [
|
||||
|
|
@ -235,7 +266,9 @@
|
|||
"reinitialize": "Réinitialiser",
|
||||
"reinitializeConfirm": "Réinitialiser toutes les catégories et mots-clés à leurs valeurs par défaut ? Les catégories des transactions seront dissociées. Cette action est irréversible.",
|
||||
"noParent": "Aucun parent (niveau supérieur)",
|
||||
"sortOrder": "Ordre de tri",
|
||||
"isInputable": "Autoriser la saisie",
|
||||
"isInputableHint": "Décocher pour masquer du budget et des listes de catégories",
|
||||
"dragToReorder": "Glisser pour réordonner ou changer le parent",
|
||||
"selectCategory": "Sélectionnez une catégorie pour voir les détails",
|
||||
"keywordCount": "Mots-clés",
|
||||
"keywordText": "Mot-clé...",
|
||||
|
|
@ -269,6 +302,7 @@
|
|||
"selectAdjustment": "Sélectionnez un ajustement",
|
||||
"category": "Catégorie",
|
||||
"noEntries": "Aucune entrée",
|
||||
"splitTransactions": "Répartitions de transactions",
|
||||
"help": {
|
||||
"title": "Comment utiliser les Ajustements",
|
||||
"tips": [
|
||||
|
|
@ -284,9 +318,19 @@
|
|||
"planned": "Prévu",
|
||||
"actual": "Réel",
|
||||
"difference": "Écart",
|
||||
"annual": "Annuel",
|
||||
"previousYear": "Année préc.",
|
||||
"splitEvenly": "Répartir également sur 12 mois",
|
||||
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
||||
"clickToEdit": "Cliquer pour modifier",
|
||||
"applyToMonth": "Appliquer au mois",
|
||||
"allMonths": "Les 12 mois",
|
||||
"expenses": "Dépenses",
|
||||
"income": "Revenus",
|
||||
"transfers": "Transferts",
|
||||
"totalExpenses": "Total des dépenses",
|
||||
"totalIncome": "Total des revenus",
|
||||
"totalTransfers": "Total des transferts",
|
||||
"totalPlanned": "Total prévu",
|
||||
"totalActual": "Total réel",
|
||||
"totalDifference": "Écart",
|
||||
|
|
@ -296,15 +340,16 @@
|
|||
"noTemplates": "Aucun modèle enregistré.",
|
||||
"templateName": "Nom du modèle",
|
||||
"templateDescription": "Description (optionnel)",
|
||||
"directSuffix": "(direct)",
|
||||
"deleteTemplateConfirm": "Supprimer ce modèle ?",
|
||||
"help": {
|
||||
"title": "Comment utiliser le Budget",
|
||||
"tips": [
|
||||
"Utilisez le navigateur de mois pour passer d'un mois à l'autre",
|
||||
"Cliquez sur un montant prévu pour le modifier — appuyez sur Entrée pour sauvegarder ou Échap pour annuler",
|
||||
"La colonne réel affiche les dépenses réelles de vos transactions importées",
|
||||
"Vert signifie sous le budget, rouge signifie au-dessus du budget",
|
||||
"Sauvegardez votre budget comme modèle et appliquez-le rapidement à d'autres mois"
|
||||
"Utilisez le navigateur d'année pour changer d'année",
|
||||
"Cliquez sur une cellule de mois pour modifier le montant prévu — Entrée pour sauvegarder, Échap pour annuler, Tab pour passer au mois suivant",
|
||||
"La colonne Annuel affiche le total des 12 mois",
|
||||
"Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois",
|
||||
"Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -314,14 +359,63 @@
|
|||
"byCategory": "Dépenses par catégorie",
|
||||
"overTime": "Catégories dans le temps",
|
||||
"trends": "Tendances mensuelles",
|
||||
"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": {
|
||||
"monthly": "Mensuel",
|
||||
"ytd": "Cumul annuel",
|
||||
"dollarVar": "$ \u00c9cart",
|
||||
"pctVar": "% \u00c9cart",
|
||||
"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",
|
||||
"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": {
|
||||
"title": "Comment utiliser les Rapports",
|
||||
"tips": [
|
||||
"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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -340,7 +434,63 @@
|
|||
"installButton": "Installer et redémarrer",
|
||||
"installing": "Installation en cours...",
|
||||
"error": "Erreur lors de la mise à jour",
|
||||
"retryButton": "Réessayer"
|
||||
"retryButton": "Réessayer",
|
||||
"releaseNotes": "Nouveautés"
|
||||
},
|
||||
"dataManagement": {
|
||||
"title": "Gestion des données",
|
||||
"export": {
|
||||
"title": "Exporter",
|
||||
"modeLabel": "Que exporter",
|
||||
"modeTransactionsWithCategories": "Transactions avec catégories",
|
||||
"modeTransactionsOnly": "Transactions uniquement",
|
||||
"modeCategoriesOnly": "Catégories uniquement",
|
||||
"formatLabel": "Format",
|
||||
"csvDisabledNote": "transactions uniquement",
|
||||
"encryptLabel": "Chiffrer avec un mot de passe",
|
||||
"passwordPlaceholder": "Mot de passe (min 8 caractères)",
|
||||
"passwordConfirmPlaceholder": "Confirmer le mot de passe",
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"button": "Exporter",
|
||||
"success": "Export terminé avec succès"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer",
|
||||
"description": "Importer des données depuis un fichier exporté précédemment. Les données existantes seront remplacées.",
|
||||
"button": "Importer depuis un fichier",
|
||||
"passwordRequired": "Ce fichier est chiffré. Entrez le mot de passe pour le déchiffrer.",
|
||||
"passwordPlaceholder": "Mot de passe",
|
||||
"decrypt": "Déchiffrer",
|
||||
"confirmTitle": "Remplacer les données",
|
||||
"willDeleteLabel": "Les données suivantes seront supprimées :",
|
||||
"willDeleteCategories": "Toutes les catégories, fournisseurs et mots-clés",
|
||||
"willDeleteTransactions": "Toutes les transactions et l'historique d'import",
|
||||
"willDeleteAll": "Toutes les transactions, catégories, fournisseurs, mots-clés et l'historique d'import",
|
||||
"willImportLabel": "Les données suivantes seront importées :",
|
||||
"countCategories": "{{count}} catégorie(s)",
|
||||
"countSuppliers": "{{count}} fournisseur(s)",
|
||||
"countKeywords": "{{count}} mot(s)-clé(s)",
|
||||
"countTransactions": "{{count}} transaction(s)",
|
||||
"irreversibleWarning": "Cette action est irréversible. Toutes les données existantes du type sélectionné seront définitivement supprimées et remplacées.",
|
||||
"typeToConfirm": "Tapez « {{word}} » pour confirmer :",
|
||||
"confirmWord": "REMPLACER",
|
||||
"replaceButton": "Remplacer les données",
|
||||
"success": "Import terminé avec succès",
|
||||
"tryAgain": "Réessayer"
|
||||
}
|
||||
},
|
||||
"userGuide": {
|
||||
"title": "Guide d'utilisation",
|
||||
"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.",
|
||||
"help": {
|
||||
|
|
@ -352,6 +502,334 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"hideCategory": "Masquer la catégorie",
|
||||
"viewTransactions": "Voir les transactions",
|
||||
"hiddenCategories": "Masquées",
|
||||
"showAll": "Tout afficher",
|
||||
"total": "Total",
|
||||
"transactions": "transactions",
|
||||
"clickToShow": "Cliquer pour afficher"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Fév",
|
||||
"mar": "Mar",
|
||||
"apr": "Avr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aoû",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Déc"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Guide d'utilisation",
|
||||
"backToSettings": "Retour aux paramètres",
|
||||
"print": "Imprimer",
|
||||
"features": "Fonctionnalités",
|
||||
"howTo": "Comment faire",
|
||||
"quickStart": "Démarrage rapide",
|
||||
"tipsHeader": "Astuces",
|
||||
"gettingStarted": {
|
||||
"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. L'application est disponible sur Windows et Linux.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"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",
|
||||
"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)",
|
||||
"Importez vos transactions, puis allez dans Catégories pour configurer les règles de mots-clés",
|
||||
"Utilisez l'auto-catégorisation sur la page Transactions pour appliquer vos règles en masse",
|
||||
"Configurez votre Budget et suivez votre progression via les Rapports"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"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.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
||||
"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": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"overview": "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.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"Définissez votre dossier d'import via le sélecteur de dossier en haut de la page",
|
||||
"Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV",
|
||||
"Cliquez sur une source pour ouvrir l'assistant d'import",
|
||||
"Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes",
|
||||
"Sélectionnez les fichiers à importer et prévisualisez les données analysées",
|
||||
"Vérifiez les doublons, examinez le résumé, puis confirmez l'import"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transactions",
|
||||
"overview": "Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'est ici que vous organisez vos données financières.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"Utilisez la barre de filtres pour affiner les transactions par texte, catégorie, source ou date",
|
||||
"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 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 répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories",
|
||||
"overview": "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.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"adjustments": {
|
||||
"title": "Ajustements",
|
||||
"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": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"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": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"budget": {
|
||||
"title": "Budget",
|
||||
"overview": "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.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"Utilisez le navigateur d'année pour sélectionner l'année du budget",
|
||||
"Cliquez sur une cellule de mois pour entrer un montant prévu",
|
||||
"Appuyez sur Entrée pour sauvegarder, Échap pour annuler, ou Tab pour passer au mois suivant",
|
||||
"Utilisez le bouton de répartition (sur la colonne Annuel) pour distribuer également sur tous les mois",
|
||||
"Sauvegardez votre budget comme modèle pour le réutiliser les années suivantes"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"reports": {
|
||||
"title": "Rapports",
|
||||
"overview": "Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.",
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"steps": [
|
||||
"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",
|
||||
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions",
|
||||
"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": [
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"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": [
|
||||
"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"
|
||||
],
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"tips": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profils",
|
||||
"select": "Sélectionnez un profil",
|
||||
"create": "Créer un profil",
|
||||
"edit": "Modifier le profil",
|
||||
"delete": "Supprimer le profil",
|
||||
"deleteConfirm": "Supprimer ce profil et toutes ses données ? Cette action est irréversible.",
|
||||
"name": "Nom du profil",
|
||||
"namePlaceholder": "Entrez un nom...",
|
||||
"color": "Couleur",
|
||||
"pin": "NIP",
|
||||
"pinSet": "NIP défini",
|
||||
"pinNotSet": "Aucun NIP",
|
||||
"setPin": "Définir un NIP",
|
||||
"removePin": "Supprimer le NIP",
|
||||
"enterPin": "Entrez votre NIP",
|
||||
"wrongPin": "NIP incorrect. Réessayez.",
|
||||
"switchProfile": "Changer de profil",
|
||||
"manageProfiles": "Gérer les profils",
|
||||
"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": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
|
|
|
|||
11
src/main.tsx
11
src/main.tsx
|
|
@ -1,11 +1,20 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import { initLogCapture } from "./services/logService";
|
||||
import "./i18n/config";
|
||||
import "./styles.css";
|
||||
|
||||
initLogCapture();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ProfileProvider>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</ProfileProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Split } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import { useAdjustments } from "../hooks/useAdjustments";
|
||||
import { getEntriesByAdjustmentId } from "../services/adjustmentService";
|
||||
import type { AdjustmentEntryWithCategory } from "../services/adjustmentService";
|
||||
import {
|
||||
getSplitParentTransactions,
|
||||
getSplitChildren,
|
||||
saveSplitAdjustment,
|
||||
deleteSplitAdjustment,
|
||||
} from "../services/transactionService";
|
||||
import type { TransactionRow } from "../shared/types";
|
||||
import AdjustmentListPanel from "../components/adjustments/AdjustmentListPanel";
|
||||
import AdjustmentDetailPanel from "../components/adjustments/AdjustmentDetailPanel";
|
||||
import SplitAdjustmentModal from "../components/transactions/SplitAdjustmentModal";
|
||||
|
||||
export default function AdjustmentsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -24,6 +32,9 @@ export default function AdjustmentsPage() {
|
|||
new Map()
|
||||
);
|
||||
|
||||
const [splitTransactions, setSplitTransactions] = useState<TransactionRow[]>([]);
|
||||
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
|
||||
|
||||
const loadAllEntries = useCallback(async () => {
|
||||
const map = new Map<number, AdjustmentEntryWithCategory[]>();
|
||||
for (const adj of state.adjustments) {
|
||||
|
|
@ -43,6 +54,32 @@ export default function AdjustmentsPage() {
|
|||
}
|
||||
}, [state.adjustments, loadAllEntries]);
|
||||
|
||||
const loadSplitTransactions = useCallback(async () => {
|
||||
try {
|
||||
const rows = await getSplitParentTransactions();
|
||||
setSplitTransactions(rows);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSplitTransactions();
|
||||
}, [loadSplitTransactions]);
|
||||
|
||||
const handleSplitSave = async (
|
||||
parentId: number,
|
||||
entries: Array<{ category_id: number; amount: number; description: string }>
|
||||
) => {
|
||||
await saveSplitAdjustment(parentId, entries);
|
||||
await loadSplitTransactions();
|
||||
};
|
||||
|
||||
const handleSplitDelete = async (parentId: number) => {
|
||||
await deleteSplitAdjustment(parentId);
|
||||
await loadSplitTransactions();
|
||||
};
|
||||
|
||||
const selectedAdjustment =
|
||||
state.selectedAdjustmentId !== null
|
||||
? state.adjustments.find((a) => a.id === state.selectedAdjustmentId) ?? null
|
||||
|
|
@ -75,12 +112,56 @@ export default function AdjustmentsPage() {
|
|||
) : (
|
||||
<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">
|
||||
<AdjustmentListPanel
|
||||
adjustments={state.adjustments}
|
||||
selectedId={state.selectedAdjustmentId}
|
||||
onSelect={selectAdjustment}
|
||||
entriesByAdjustment={entriesMap}
|
||||
/>
|
||||
{state.adjustments.length > 0 && (
|
||||
<AdjustmentListPanel
|
||||
adjustments={state.adjustments}
|
||||
selectedId={state.selectedAdjustmentId}
|
||||
onSelect={selectAdjustment}
|
||||
entriesByAdjustment={entriesMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
{splitTransactions.length > 0 && (
|
||||
<>
|
||||
{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)]" />
|
||||
<span className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("adjustments.splitTransactions")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{splitTransactions.map((tx) => (
|
||||
<button
|
||||
key={tx.id}
|
||||
onClick={() => setSplitRow(tx)}
|
||||
className="w-full flex flex-col gap-1 px-3 py-2.5 text-left rounded-lg transition-colors hover:bg-[var(--muted)]/50"
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{tx.description}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{tx.date}</span>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
tx.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
{tx.amount >= 0 ? "+" : ""}
|
||||
{tx.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<AdjustmentDetailPanel
|
||||
selectedAdjustment={selectedAdjustment}
|
||||
|
|
@ -97,6 +178,17 @@ export default function AdjustmentsPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitRow && (
|
||||
<SplitAdjustmentModal
|
||||
transaction={splitRow}
|
||||
categories={state.categories}
|
||||
onLoadChildren={getSplitChildren}
|
||||
onSave={handleSplitSave}
|
||||
onDelete={handleSplitDelete}
|
||||
onClose={() => setSplitRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import { useBudget } from "../hooks/useBudget";
|
||||
import MonthNavigator from "../components/budget/MonthNavigator";
|
||||
import BudgetSummaryCards from "../components/budget/BudgetSummaryCards";
|
||||
import YearNavigator from "../components/budget/YearNavigator";
|
||||
import BudgetTable from "../components/budget/BudgetTable";
|
||||
import TemplateActions from "../components/budget/TemplateActions";
|
||||
|
||||
|
|
@ -10,14 +9,16 @@ export default function BudgetPage() {
|
|||
const { t } = useTranslation();
|
||||
const {
|
||||
state,
|
||||
navigateMonth,
|
||||
navigateYear,
|
||||
updatePlanned,
|
||||
splitEvenly,
|
||||
saveTemplate,
|
||||
applyTemplate,
|
||||
applyTemplateAllMonths,
|
||||
deleteTemplate,
|
||||
} = useBudget();
|
||||
|
||||
const { year, month, rows, templates, isLoading, isSaving, error } = state;
|
||||
const { year, rows, templates, isLoading, isSaving, error } = state;
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
|
|
@ -30,11 +31,12 @@ export default function BudgetPage() {
|
|||
<TemplateActions
|
||||
templates={templates}
|
||||
onApply={applyTemplate}
|
||||
onApplyAllMonths={applyTemplateAllMonths}
|
||||
onSave={saveTemplate}
|
||||
onDelete={deleteTemplate}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<MonthNavigator year={year} month={month} onNavigate={navigateMonth} />
|
||||
<YearNavigator year={year} onNavigate={navigateYear} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -44,8 +46,11 @@ export default function BudgetPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<BudgetSummaryCards rows={rows} />
|
||||
<BudgetTable rows={rows} onUpdatePlanned={updatePlanned} />
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={updatePlanned}
|
||||
onSplitEvenly={splitEvenly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, RotateCcw, List } from "lucide-react";
|
||||
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import CategoryTree from "../components/categories/CategoryTree";
|
||||
|
|
@ -10,6 +10,7 @@ import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
|||
export default function CategoriesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [showAllKeywords, setShowAllKeywords] = useState(false);
|
||||
const [showReinitConfirm, setShowReinitConfirm] = useState(false);
|
||||
const {
|
||||
state,
|
||||
selectCategory,
|
||||
|
|
@ -22,12 +23,12 @@ export default function CategoriesPage() {
|
|||
editKeyword,
|
||||
removeKeyword,
|
||||
reinitializeCategories,
|
||||
moveCategory,
|
||||
} = useCategories();
|
||||
|
||||
const handleReinitialize = async () => {
|
||||
if (confirm(t("categories.reinitializeConfirm"))) {
|
||||
await reinitializeCategories();
|
||||
}
|
||||
setShowReinitConfirm(false);
|
||||
await reinitializeCategories();
|
||||
};
|
||||
|
||||
const selectedCategory =
|
||||
|
|
@ -55,7 +56,7 @@ export default function CategoriesPage() {
|
|||
{t("categories.allKeywords")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReinitialize}
|
||||
onClick={() => setShowReinitConfirm(true)}
|
||||
disabled={state.isSaving}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -86,14 +87,16 @@ export default function CategoriesPage() {
|
|||
setShowAllKeywords(false);
|
||||
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">
|
||||
<CategoryTree
|
||||
tree={state.tree}
|
||||
selectedId={state.selectedCategoryId}
|
||||
onSelect={selectCategory}
|
||||
onMoveCategory={moveCategory}
|
||||
/>
|
||||
</div>
|
||||
<CategoryDetailPanel
|
||||
|
|
@ -113,6 +116,37 @@ export default function CategoriesPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reinitialize confirmation modal */}
|
||||
{showReinitConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-[var(--negative)]/10">
|
||||
<AlertTriangle size={20} className="text-[var(--negative)]" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("categories.reinitialize")}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-6">
|
||||
{t("categories.reinitializeConfirm")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowReinitConfirm(false)}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReinitialize}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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,17 +1,40 @@
|
|||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
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 type { CategoryBreakdownItem } from "../shared/types";
|
||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setPeriod } = useDashboard();
|
||||
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
||||
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
||||
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||
|
||||
const toggleHidden = useCallback((name: string) => {
|
||||
setHiddenCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
|
||||
|
||||
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
|
||||
setDetailModal(item);
|
||||
}, []);
|
||||
|
||||
const balance = summary.totalAmount;
|
||||
const balanceColor =
|
||||
|
|
@ -42,6 +65,10 @@ export default function DashboardPage() {
|
|||
},
|
||||
];
|
||||
|
||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||
|
||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
|
|
@ -49,10 +76,16 @@ export default function DashboardPage() {
|
|||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||
<PageHelp helpKey="dashboard" />
|
||||
</div>
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={state.customDateFrom}
|
||||
customDateTo={state.customDateTo}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
</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) => (
|
||||
<div
|
||||
key={card.labelKey}
|
||||
|
|
@ -69,10 +102,60 @@ export default function DashboardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<CategoryPieChart data={categoryBreakdown} />
|
||||
<RecentTransactionsList transactions={recentTransactions} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="lg:col-span-1">
|
||||
<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}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detailModal && (
|
||||
<TransactionDetailModal
|
||||
categoryId={detailModal.category_id}
|
||||
categoryName={detailModal.category_name}
|
||||
categoryColor={detailModal.category_color}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onClose={() => setDetailModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
230
src/pages/DocsPage.tsx
Normal file
230
src/pages/DocsPage.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Rocket,
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
ArrowLeftRight,
|
||||
Tags,
|
||||
SlidersHorizontal,
|
||||
PiggyBank,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ArrowLeft,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Footprints,
|
||||
Printer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "gettingStarted", icon: Rocket },
|
||||
{ key: "profiles", icon: Users },
|
||||
{ key: "dashboard", icon: LayoutDashboard },
|
||||
{ key: "import", icon: Upload },
|
||||
{ key: "transactions", icon: ArrowLeftRight },
|
||||
{ key: "categories", icon: Tags },
|
||||
{ key: "adjustments", icon: SlidersHorizontal },
|
||||
{ key: "budget", icon: PiggyBank },
|
||||
{ key: "reports", icon: BarChart3 },
|
||||
{ key: "settings", icon: Settings },
|
||||
] as const;
|
||||
|
||||
export default function DocsPage() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
|
||||
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll spy via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const container = contentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
rootMargin: "-10% 0px -80% 0px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
for (const { key } of SECTIONS) {
|
||||
const el = sectionRefs.current[key];
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Handle initial anchor from URL
|
||||
useEffect(() => {
|
||||
const hash = location.hash.replace("#", "");
|
||||
if (hash && sectionRefs.current[hash]) {
|
||||
requestAnimationFrame(() => {
|
||||
sectionRefs.current[hash]?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
const scrollToSection = (key: string) => {
|
||||
sectionRefs.current[key]?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Sidebar TOC */}
|
||||
<nav className="w-56 shrink-0 border-r border-[var(--border)] p-4 overflow-y-auto">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t("docs.backToSettings")}
|
||||
</Link>
|
||||
|
||||
<h2 className="text-sm font-semibold text-[var(--muted-foreground)] uppercase tracking-wider mb-3">
|
||||
{t("docs.title")}
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{SECTIONS.map(({ key, icon: Icon }) => (
|
||||
<li key={key}>
|
||||
<button
|
||||
onClick={() => scrollToSection(key)}
|
||||
className={`flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === key
|
||||
? "bg-[var(--primary)] text-white font-medium"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{t(`docs.${key}.title`)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div ref={contentRef} className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("docs.title")}</h1>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
title={t("docs.print")}
|
||||
>
|
||||
<Printer size={16} />
|
||||
{t("docs.print")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{SECTIONS.map(({ key, icon: Icon }) => (
|
||||
<section
|
||||
key={key}
|
||||
id={key}
|
||||
ref={(el) => {
|
||||
sectionRefs.current[key] = el;
|
||||
}}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4"
|
||||
>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(`docs.${key}.title`)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{t(`docs.${key}.overview`)}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||
<ListChecks size={14} />
|
||||
{t("docs.features")}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{(
|
||||
t(`docs.${key}.features`, {
|
||||
returnObjects: true,
|
||||
}) as string[]
|
||||
).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="text-[var(--primary)] mt-0.5 shrink-0">•</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||
<Footprints size={14} />
|
||||
{key === "gettingStarted"
|
||||
? t("docs.quickStart")
|
||||
: t("docs.howTo")}
|
||||
</h3>
|
||||
<ol className="space-y-1 list-decimal list-inside">
|
||||
{(
|
||||
t(`docs.${key}.steps`, {
|
||||
returnObjects: true,
|
||||
}) as string[]
|
||||
).map((item, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="bg-[var(--background)] rounded-lg p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||
<Lightbulb size={14} />
|
||||
{t("docs.tipsHeader")}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{(
|
||||
t(`docs.${key}.tips`, {
|
||||
returnObjects: true,
|
||||
}) as string[]
|
||||
).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
|
||||
>
|
||||
<Lightbulb size={13} className="text-[var(--primary)] mt-0.5 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImportWizard } from "../hooks/useImportWizard";
|
||||
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
||||
import SourceList from "../components/import/SourceList";
|
||||
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
||||
import FilePreviewTable from "../components/import/FilePreviewTable";
|
||||
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
|
||||
import ImportConfirmation from "../components/import/ImportConfirmation";
|
||||
import ImportProgress from "../components/import/ImportProgress";
|
||||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||
import WizardNavigation from "../components/import/WizardNavigation";
|
||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import FilePreviewModal from "../components/import/FilePreviewModal";
|
||||
import { AlertCircle, Eye, X, ChevronLeft } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
|
||||
export default function ImportPage() {
|
||||
|
|
@ -24,15 +25,28 @@ export default function ImportPage() {
|
|||
toggleFile,
|
||||
selectAllFiles,
|
||||
parsePreview,
|
||||
checkDuplicates,
|
||||
parseAndCheckDuplicates,
|
||||
executeImport,
|
||||
goToStep,
|
||||
reset,
|
||||
autoDetectConfig,
|
||||
saveConfigAsTemplate,
|
||||
applyConfigTemplate,
|
||||
updateConfigTemplate,
|
||||
deleteConfigTemplate,
|
||||
toggleDuplicateRow,
|
||||
setSkipAllDuplicates,
|
||||
} = useImportWizard();
|
||||
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
await parsePreview();
|
||||
setShowPreviewModal(true);
|
||||
}, [parsePreview]);
|
||||
|
||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
|
|
@ -64,7 +78,7 @@ export default function ImportPage() {
|
|||
<SourceList
|
||||
sources={state.scannedSources}
|
||||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileHashes={state.importedFilesBySource}
|
||||
importedFileNames={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
/>
|
||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||
|
|
@ -77,46 +91,55 @@ export default function ImportPage() {
|
|||
source={state.selectedSource}
|
||||
config={state.sourceConfig}
|
||||
selectedFiles={state.selectedFiles}
|
||||
importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)}
|
||||
headers={state.previewHeaders}
|
||||
configTemplates={state.configTemplates}
|
||||
onConfigChange={updateConfig}
|
||||
onFileToggle={toggleFile}
|
||||
onSelectAllFiles={selectAllFiles}
|
||||
onAutoDetect={autoDetectConfig}
|
||||
onSaveAsTemplate={saveConfigAsTemplate}
|
||||
onApplyTemplate={applyConfigTemplate}
|
||||
onUpdateTemplate={updateConfigTemplate}
|
||||
onDeleteTemplate={deleteConfigTemplate}
|
||||
selectedTemplateId={state.selectedTemplateId}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
<WizardNavigation
|
||||
onBack={() => goToStep("source-list")}
|
||||
onNext={parsePreview}
|
||||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.preview")}
|
||||
nextDisabled={
|
||||
state.selectedFiles.length === 0 || !state.sourceConfig.name
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.step === "file-preview" && (
|
||||
<div className="space-y-6">
|
||||
<FilePreviewTable
|
||||
rows={state.parsedPreview.slice(0, 20)}
|
||||
/>
|
||||
{state.parsedPreview.length > 20 && (
|
||||
<p className="text-sm text-[var(--muted-foreground)] text-center">
|
||||
{t("import.preview.moreRows", {
|
||||
count: state.parsedPreview.length - 20,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<WizardNavigation
|
||||
onBack={() => goToStep("source-config")}
|
||||
onNext={checkDuplicates}
|
||||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.checkDuplicates")}
|
||||
nextDisabled={
|
||||
state.parsedPreview.filter((r) => r.parsed).length === 0
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]">
|
||||
<div>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => goToStep("source-list")}
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t("import.wizard.back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={nextDisabled || state.isLoading}
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Eye size={16} />
|
||||
{t("import.wizard.preview")}
|
||||
</button>
|
||||
<button
|
||||
onClick={parseAndCheckDuplicates}
|
||||
disabled={nextDisabled || state.isLoading}
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("import.wizard.checkDuplicates")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -130,7 +153,7 @@ export default function ImportPage() {
|
|||
onIncludeAll={() => setSkipAllDuplicates(false)}
|
||||
/>
|
||||
<WizardNavigation
|
||||
onBack={() => goToStep("file-preview")}
|
||||
onBack={() => goToStep("source-config")}
|
||||
onNext={() => goToStep("confirm")}
|
||||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.confirm")}
|
||||
|
|
@ -168,6 +191,15 @@ export default function ImportPage() {
|
|||
{state.step === "report" && state.importReport && (
|
||||
<ImportReportPanel report={state.importReport} onDone={reset} />
|
||||
)}
|
||||
|
||||
{/* Preview modal */}
|
||||
{showPreviewModal && state.parsedPreview.length > 0 && (
|
||||
<FilePreviewModal
|
||||
rows={state.parsedPreview.slice(0, 20)}
|
||||
totalCount={state.parsedPreview.length}
|
||||
onClose={() => setShowPreviewModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
src/pages/ProfileSelectionPage.tsx
Normal file
87
src/pages/ProfileSelectionPage.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock, Plus } from "lucide-react";
|
||||
import { useProfile } from "../contexts/ProfileContext";
|
||||
import { APP_NAME } from "../shared/constants";
|
||||
import PinDialog from "../components/profile/PinDialog";
|
||||
import ProfileFormModal from "../components/profile/ProfileFormModal";
|
||||
|
||||
export default function ProfileSelectionPage() {
|
||||
const { t } = useTranslation();
|
||||
const { profiles, switchProfile } = useProfile();
|
||||
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const handleSelect = (profileId: string) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
if (!profile) return;
|
||||
|
||||
if (profile.pin_hash) {
|
||||
setPinProfileId(profileId);
|
||||
} else {
|
||||
switchProfile(profileId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = () => {
|
||||
if (pinProfileId) {
|
||||
switchProfile(pinProfileId);
|
||||
setPinProfileId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pinProfile = profiles.find((p) => p.id === pinProfileId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-[var(--background)] p-8">
|
||||
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-2">{APP_NAME}</h1>
|
||||
<p className="text-[var(--muted-foreground)] mb-10">{t("profile.select")}</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 max-w-lg w-full">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => handleSelect(profile.id)}
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||
style={{ backgroundColor: profile.color }}
|
||||
>
|
||||
{profile.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">{profile.name}</span>
|
||||
{profile.pin_hash && (
|
||||
<Lock size={14} className="text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
|
||||
<Plus size={24} className="text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{t("profile.create")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pinProfileId && pinProfile && (
|
||||
<PinDialog
|
||||
profileName={pinProfile.name}
|
||||
storedHash={pinProfile.pin_hash!}
|
||||
onSuccess={handlePinSuccess}
|
||||
onCancel={() => setPinProfileId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<ProfileFormModal onClose={() => setShowCreate(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +1,115 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Hash, Table, BarChart3 } from "lucide-react";
|
||||
import { useReports } from "../hooks/useReports";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import type { ReportTab } from "../shared/types";
|
||||
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
|
||||
import { getAllSources } from "../services/importSourceService";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
import CategoryTable from "../components/reports/CategoryTable";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||
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 { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
|
||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setTab, setPeriod } = useReports();
|
||||
const { t, i18n } = useTranslation();
|
||||
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 [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) => {
|
||||
setHiddenCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
|
||||
|
||||
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
|
||||
setDetailModal(item);
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<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="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" />
|
||||
</div>
|
||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||
{state.tab !== "budgetVsActual" && (
|
||||
<PeriodSelector
|
||||
value={state.period}
|
||||
onChange={setPeriod}
|
||||
customDateFrom={state.customDateFrom}
|
||||
customDateTo={state.customDateTo}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="flex gap-2 mb-6 flex-wrap items-center">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
|
|
@ -37,6 +123,54 @@ export default function ReportsPage() {
|
|||
{t(`reports.${tab}`)}
|
||||
</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>
|
||||
|
||||
{state.error && (
|
||||
|
|
@ -45,9 +179,77 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
||||
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
|
||||
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
|
||||
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
|
||||
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
|
||||
{state.tab === "trends" && (
|
||||
viewMode === "chart" ? (
|
||||
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
|
||||
) : (
|
||||
<MonthlyTrendsTable data={state.monthlyTrends} />
|
||||
)
|
||||
)}
|
||||
{state.tab === "byCategory" && (
|
||||
viewMode === "chart" ? (
|
||||
<CategoryBarChart
|
||||
data={state.categorySpending}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
showAmounts={showAmounts}
|
||||
/>
|
||||
) : (
|
||||
<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 && (
|
||||
<TransactionDetailModal
|
||||
categoryId={detailModal.category_id}
|
||||
categoryName={detailModal.category_name}
|
||||
categoryColor={detailModal.category_color}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onClose={() => setDetailModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Info,
|
||||
|
|
@ -9,22 +9,57 @@ import {
|
|||
RotateCcw,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { useUpdater } from "../hooks/useUpdater";
|
||||
import { Link } from "react-router-dom";
|
||||
import { APP_NAME } from "../shared/constants";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import DataManagementCard from "../components/settings/DataManagementCard";
|
||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||
useUpdater();
|
||||
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(() => {
|
||||
getVersion().then(setVersion);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "available" && state.version) {
|
||||
fetchReleaseNotes(state.version);
|
||||
}
|
||||
}, [state.status, state.version, fetchReleaseNotes]);
|
||||
|
||||
const progressPercent =
|
||||
state.contentLength && state.contentLength > 0
|
||||
? Math.round((state.progress / state.contentLength) * 100)
|
||||
|
|
@ -52,6 +87,48 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* User guide card */}
|
||||
<Link
|
||||
to="/docs"
|
||||
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)]">
|
||||
<BookOpen size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("settings.userGuide.title")}</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.userGuide.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
|
||||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
|
|
@ -100,6 +177,30 @@ export default function SettingsPage() {
|
|||
<p>
|
||||
{t("settings.updates.available", { version: state.version })}
|
||||
</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
|
||||
onClick={downloadAndInstall}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
|
|
@ -170,6 +271,12 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<LogViewerCard />
|
||||
|
||||
{/* Data management */}
|
||||
<DataManagementCard />
|
||||
|
||||
{/* Data safety notice */}
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
|
|||
|
||||
export default function TransactionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory } =
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
||||
useTransactions();
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -81,6 +81,9 @@ export default function TransactionsPage() {
|
|||
onCategoryChange={updateCategory}
|
||||
onNotesChange={saveNotes}
|
||||
onAddKeyword={addKeywordToCategory}
|
||||
onLoadSplitChildren={loadSplitChildren}
|
||||
onSaveSplit={saveSplit}
|
||||
onDeleteSplit={deleteSplit}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
BudgetEntry,
|
||||
BudgetTemplate,
|
||||
BudgetTemplateEntry,
|
||||
BudgetVsActualRow,
|
||||
} from "../shared/types";
|
||||
|
||||
function computeMonthDateRange(year: number, month: number) {
|
||||
|
|
@ -14,6 +15,13 @@ function computeMonthDateRange(year: number, month: number) {
|
|||
}
|
||||
|
||||
export async function getActiveCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>(
|
||||
"SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllActiveCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>(
|
||||
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
|
||||
|
|
@ -74,6 +82,41 @@ export async function getActualsByCategory(
|
|||
);
|
||||
}
|
||||
|
||||
export async function getBudgetEntriesForYear(
|
||||
year: number
|
||||
): Promise<BudgetEntry[]> {
|
||||
const db = await getDb();
|
||||
return db.select<BudgetEntry[]>(
|
||||
"SELECT * FROM budget_entries WHERE year = $1",
|
||||
[year]
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertBudgetEntriesForYear(
|
||||
categoryId: number,
|
||||
year: number,
|
||||
amounts: number[]
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const month = m + 1;
|
||||
const amount = amounts[m] ?? 0;
|
||||
if (amount === 0) {
|
||||
await db.execute(
|
||||
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
|
||||
[categoryId, year, month]
|
||||
);
|
||||
} else {
|
||||
await db.execute(
|
||||
`INSERT INTO budget_entries (category_id, year, month, amount)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, updated_at = CURRENT_TIMESTAMP`,
|
||||
[categoryId, year, month, amount]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Templates
|
||||
|
||||
export async function getAllTemplates(): Promise<BudgetTemplate[]> {
|
||||
|
|
@ -134,3 +177,255 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
|||
);
|
||||
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 ---
|
||||
|
||||
async function getActualsByCategoryRange(
|
||||
dateFrom: string,
|
||||
dateTo: string
|
||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||
const db = await getDb();
|
||||
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
||||
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
||||
FROM transactions
|
||||
WHERE date BETWEEN $1 AND $2
|
||||
GROUP BY category_id`,
|
||||
[dateFrom, dateTo]
|
||||
);
|
||||
}
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||
|
||||
export async function getBudgetVsActualData(
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<BudgetVsActualRow[]> {
|
||||
// Date ranges
|
||||
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
||||
const ytdFrom = `${year}-01-01`;
|
||||
const ytdTo = monthTo;
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
||||
getAllActiveCategories(),
|
||||
getBudgetEntriesForYear(year),
|
||||
getActualsByCategoryRange(monthFrom, monthTo),
|
||||
getActualsByCategoryRange(ytdFrom, ytdTo),
|
||||
]);
|
||||
|
||||
// Build maps
|
||||
const entryMap = new Map<number, Map<number, number>>();
|
||||
for (const e of yearEntries) {
|
||||
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
|
||||
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||
}
|
||||
|
||||
const monthActualMap = new Map<number, number>();
|
||||
for (const a of monthActuals) {
|
||||
if (a.category_id != null) monthActualMap.set(a.category_id, a.actual);
|
||||
}
|
||||
|
||||
const ytdActualMap = new Map<number, number>();
|
||||
for (const a of ytdActuals) {
|
||||
if (a.category_id != null) ytdActualMap.set(a.category_id, a.actual);
|
||||
}
|
||||
|
||||
// Index categories
|
||||
const childrenByParent = new Map<number, Category[]>();
|
||||
for (const cat of allCategories) {
|
||||
if (cat.parent_id) {
|
||||
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
|
||||
childrenByParent.get(cat.parent_id)!.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
// Sign multiplier: budget stored positive, expenses displayed negative
|
||||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||
|
||||
// Compute leaf row values
|
||||
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
||||
const sign = signFor(cat.type);
|
||||
const monthMap = entryMap.get(cat.id);
|
||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||
const monthBudget = rawMonthBudget * sign;
|
||||
|
||||
let rawYtdBudget = 0;
|
||||
for (let m = 1; m <= month; m++) {
|
||||
rawYtdBudget += monthMap?.get(m) ?? 0;
|
||||
}
|
||||
const ytdBudget = rawYtdBudget * sign;
|
||||
|
||||
const monthActual = monthActualMap.get(cat.id) ?? 0;
|
||||
const ytdActual = ytdActualMap.get(cat.id) ?? 0;
|
||||
|
||||
const monthVariation = monthActual - monthBudget;
|
||||
const ytdVariation = ytdActual - ytdBudget;
|
||||
|
||||
return {
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
category_color: cat.color || "#9ca3af",
|
||||
category_type: cat.type,
|
||||
parent_id: parentId,
|
||||
is_parent: false,
|
||||
depth,
|
||||
monthActual,
|
||||
monthBudget,
|
||||
monthVariation,
|
||||
monthVariationPct: monthBudget !== 0 ? monthVariation / Math.abs(monthBudget) : null,
|
||||
ytdActual,
|
||||
ytdBudget,
|
||||
ytdVariation,
|
||||
ytdVariationPct: ytdBudget !== 0 ? ytdVariation / Math.abs(ytdBudget) : null,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
return (
|
||||
r.monthActual === 0 &&
|
||||
r.monthBudget === 0 &&
|
||||
r.ytdActual === 0 &&
|
||||
r.ytdBudget === 0
|
||||
);
|
||||
}
|
||||
|
||||
// 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 topLevel = allCategories.filter((c) => !c.parent_id);
|
||||
|
||||
for (const cat of topLevel) {
|
||||
const children = childrenByParent.get(cat.id) || [];
|
||||
const hasChildren = children.some(
|
||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||
);
|
||||
|
||||
if (!hasChildren && cat.is_inputable) {
|
||||
// Standalone leaf at level 0
|
||||
const leaf = buildLeaf(cat, null, 0);
|
||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||
} else if (hasChildren) {
|
||||
const allChildRows: BudgetVsActualRow[] = [];
|
||||
|
||||
// Direct transactions on the parent itself
|
||||
if (cat.is_inputable) {
|
||||
const direct = buildLeaf(cat, cat.id, 1);
|
||||
direct.category_name = `${cat.name} (direct)`;
|
||||
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||
}
|
||||
|
||||
// Process children in alphabetical order
|
||||
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (allChildRows.length === 0) continue;
|
||||
|
||||
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
||||
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||
|
||||
rows.push(parent);
|
||||
rows.push(...allChildRows);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
return rowOrder.get(a)! - rowOrder.get(b)!;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,66 @@ function normalizeDescription(desc: string): string {
|
|||
.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 {
|
||||
category_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.
|
||||
* 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"
|
||||
);
|
||||
|
||||
const compiled = compileKeywords(keywords);
|
||||
const normalized = normalizeDescription(description);
|
||||
|
||||
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 };
|
||||
return matchDescription(normalized, compiled);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,18 +105,10 @@ export async function categorizeBatch(
|
|||
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
||||
);
|
||||
|
||||
const compiled = compileKeywords(keywords);
|
||||
|
||||
return descriptions.map((desc) => {
|
||||
const normalized = normalizeDescription(desc);
|
||||
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 };
|
||||
return matchDescription(normalized, compiled);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface CategoryRow {
|
|||
icon: string | null;
|
||||
type: "expense" | "income" | "transfer";
|
||||
is_active: boolean;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
keyword_count: number;
|
||||
}
|
||||
|
|
@ -25,18 +26,53 @@ 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: {
|
||||
name: string;
|
||||
type: string;
|
||||
color: string;
|
||||
parent_id: number | null;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
}): Promise<number> {
|
||||
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(
|
||||
`INSERT INTO categories (name, type, color, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.sort_order]
|
||||
`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]
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -47,28 +83,107 @@ export async function updateCategory(
|
|||
type: string;
|
||||
color: string;
|
||||
parent_id: number | null;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute(
|
||||
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, sort_order = $5 WHERE id = $6`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.sort_order, id]
|
||||
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getNextSortOrder(parentId: number | null): Promise<number> {
|
||||
const db = await getDb();
|
||||
const rows = parentId === null
|
||||
? await db.select<Array<{ max_sort: number | null }>>(
|
||||
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id IS NULL`
|
||||
)
|
||||
: await db.select<Array<{ max_sort: number | null }>>(
|
||||
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id = $1`,
|
||||
[parentId]
|
||||
);
|
||||
return (rows[0]?.max_sort ?? 0) + 1;
|
||||
}
|
||||
|
||||
export async function hasDuplicateSortOrders(): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<Array<{ cnt: number }>>(
|
||||
`SELECT COUNT(*) AS cnt FROM (
|
||||
SELECT parent_id, sort_order FROM categories WHERE is_active = 1
|
||||
GROUP BY parent_id, sort_order HAVING COUNT(*) > 1
|
||||
)`
|
||||
);
|
||||
return (rows[0]?.cnt ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function fixDuplicateSortOrders(): Promise<void> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<Array<{ id: number; parent_id: number | null }>>(
|
||||
`SELECT id, parent_id FROM categories WHERE is_active = 1 ORDER BY parent_id, sort_order, name`
|
||||
);
|
||||
|
||||
let currentParentId: number | null | undefined = undefined;
|
||||
let seq = 0;
|
||||
for (const row of rows) {
|
||||
if (row.parent_id !== currentParentId) {
|
||||
currentParentId = row.parent_id;
|
||||
seq = 0;
|
||||
}
|
||||
seq++;
|
||||
await db.execute(
|
||||
`UPDATE categories SET sort_order = $1 WHERE id = $2`,
|
||||
[seq, row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCategorySortOrders(
|
||||
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
for (const u of updates) {
|
||||
await db.execute(
|
||||
`UPDATE categories SET sort_order = $1, parent_id = $2 WHERE id = $3`,
|
||||
[u.sort_order, u.parent_id, u.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateCategory(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
// Promote children to root level so they don't become orphans
|
||||
await db.execute(
|
||||
`UPDATE categories SET parent_id = NULL WHERE parent_id = $1`,
|
||||
// Remember the parent before deactivating
|
||||
const rows = await db.select<Array<{ parent_id: number | null }>>(
|
||||
`SELECT parent_id FROM categories WHERE id = $1`,
|
||||
[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
|
||||
await db.execute(
|
||||
`UPDATE categories SET is_active = 0 WHERE id = $1`,
|
||||
[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> {
|
||||
|
|
@ -82,9 +197,15 @@ export async function getCategoryUsageCount(id: number): Promise<number> {
|
|||
|
||||
export async function getChildrenUsageCount(parentId: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
// Check descendants recursively (up to 2 levels deep)
|
||||
const rows = await db.select<Array<{ cnt: number }>>(
|
||||
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN
|
||||
(SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1)`,
|
||||
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN (
|
||||
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]
|
||||
);
|
||||
return rows[0]?.cnt ?? 0;
|
||||
|
|
@ -113,47 +234,61 @@ export async function reinitializeCategories(): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
// Re-seed child categories
|
||||
const children: Array<[number, string, number, string, string, number]> = [
|
||||
[10, "Paie", 1, "income", "#22c55e", 1],
|
||||
[11, "Autres revenus", 1, "income", "#4ade80", 2],
|
||||
[20, "Loyer", 2, "expense", "#ef4444", 1],
|
||||
[21, "Électricité", 2, "expense", "#f59e0b", 2],
|
||||
[22, "Épicerie", 2, "expense", "#10b981", 3],
|
||||
[23, "Dons", 2, "expense", "#ec4899", 4],
|
||||
[24, "Restaurant", 2, "expense", "#f97316", 5],
|
||||
[25, "Frais bancaires", 2, "expense", "#6b7280", 6],
|
||||
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7],
|
||||
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8],
|
||||
[28, "Transport en commun", 2, "expense", "#3b82f6", 9],
|
||||
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10],
|
||||
[30, "Animaux", 2, "expense", "#a855f7", 11],
|
||||
[31, "Assurances", 2, "expense", "#14b8a6", 12],
|
||||
[32, "Pharmacie", 2, "expense", "#f43f5e", 13],
|
||||
[33, "Taxes municipales", 2, "expense", "#78716c", 14],
|
||||
[40, "Voiture", 3, "expense", "#64748b", 1],
|
||||
[41, "Amazon", 3, "expense", "#f59e0b", 2],
|
||||
[42, "Électroniques", 3, "expense", "#3b82f6", 3],
|
||||
[43, "Alcool", 3, "expense", "#7c3aed", 4],
|
||||
[44, "Cadeaux", 3, "expense", "#ec4899", 5],
|
||||
[45, "Vêtements", 3, "expense", "#d946ef", 6],
|
||||
[46, "CPA", 3, "expense", "#0ea5e9", 7],
|
||||
[47, "Voyage", 3, "expense", "#f97316", 8],
|
||||
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9],
|
||||
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10],
|
||||
[50, "Hypothèque", 4, "expense", "#dc2626", 1],
|
||||
[51, "Achats maison", 4, "expense", "#ea580c", 2],
|
||||
[52, "Entretien maison", 4, "expense", "#ca8a04", 3],
|
||||
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4],
|
||||
[54, "Outils", 4, "expense", "#b45309", 5],
|
||||
[60, "Placements", 5, "transfer", "#2563eb", 1],
|
||||
[61, "Transferts", 5, "transfer", "#7c3aed", 2],
|
||||
[70, "Impôts", 6, "expense", "#dc2626", 1],
|
||||
[71, "Paiement CC", 6, "transfer", "#6b7280", 2],
|
||||
[72, "Retrait cash", 6, "expense", "#57534e", 3],
|
||||
[73, "Projets", 6, "expense", "#0ea5e9", 4],
|
||||
// Re-seed child categories (level 2)
|
||||
// Note: Assurances (31) is now a non-inputable intermediate parent with level-3 children
|
||||
const children: Array<[number, string, number, string, string, number, boolean]> = [
|
||||
[10, "Paie", 1, "income", "#22c55e", 1, true],
|
||||
[11, "Autres revenus", 1, "income", "#4ade80", 2, true],
|
||||
[20, "Loyer", 2, "expense", "#ef4444", 1, true],
|
||||
[21, "Électricité", 2, "expense", "#f59e0b", 2, true],
|
||||
[22, "Épicerie", 2, "expense", "#10b981", 3, true],
|
||||
[23, "Dons", 2, "expense", "#ec4899", 4, true],
|
||||
[24, "Restaurant", 2, "expense", "#f97316", 5, true],
|
||||
[25, "Frais bancaires", 2, "expense", "#6b7280", 6, true],
|
||||
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7, true],
|
||||
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8, true],
|
||||
[28, "Transport en commun", 2, "expense", "#3b82f6", 9, true],
|
||||
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10, true],
|
||||
[30, "Animaux", 2, "expense", "#a855f7", 11, true],
|
||||
[31, "Assurances", 2, "expense", "#14b8a6", 12, false], // intermediate parent
|
||||
[32, "Pharmacie", 2, "expense", "#f43f5e", 13, true],
|
||||
[33, "Taxes municipales", 2, "expense", "#78716c", 14, true],
|
||||
[40, "Voiture", 3, "expense", "#64748b", 1, true],
|
||||
[41, "Amazon", 3, "expense", "#f59e0b", 2, true],
|
||||
[42, "Électroniques", 3, "expense", "#3b82f6", 3, true],
|
||||
[43, "Alcool", 3, "expense", "#7c3aed", 4, true],
|
||||
[44, "Cadeaux", 3, "expense", "#ec4899", 5, true],
|
||||
[45, "Vêtements", 3, "expense", "#d946ef", 6, true],
|
||||
[46, "CPA", 3, "expense", "#0ea5e9", 7, true],
|
||||
[47, "Voyage", 3, "expense", "#f97316", 8, true],
|
||||
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9, true],
|
||||
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10, true],
|
||||
[50, "Hypothèque", 4, "expense", "#dc2626", 1, true],
|
||||
[51, "Achats maison", 4, "expense", "#ea580c", 2, true],
|
||||
[52, "Entretien maison", 4, "expense", "#ca8a04", 3, true],
|
||||
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4, true],
|
||||
[54, "Outils", 4, "expense", "#b45309", 5, true],
|
||||
[60, "Placements", 5, "transfer", "#2563eb", 1, true],
|
||||
[61, "Transferts", 5, "transfer", "#7c3aed", 2, true],
|
||||
[70, "Impôts", 6, "expense", "#dc2626", 1, true],
|
||||
[71, "Paiement CC", 6, "transfer", "#6b7280", 2, true],
|
||||
[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(
|
||||
"INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
[id, name, parentId, type, color, sort]
|
||||
|
|
@ -177,7 +312,7 @@ export async function reinitializeCategories(): Promise<void> {
|
|||
["GARE CENTRALE", 28], ["REM", 28],
|
||||
["VIDEOTRON", 29], ["ORICOM", 29],
|
||||
["MONDOU", 30],
|
||||
["BELAIR", 31], ["PRYSM", 31], ["INS/ASS", 31],
|
||||
["BELAIR", 310], ["PRYSM", 311], ["INS/ASS", 312],
|
||||
["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32],
|
||||
["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33],
|
||||
["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
RecentTransaction,
|
||||
TransactionRow,
|
||||
} from "../shared/types";
|
||||
|
||||
export async function getDashboardSummary(
|
||||
|
|
@ -52,7 +53,8 @@ export async function getDashboardSummary(
|
|||
|
||||
export async function getExpensesByCategory(
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
dateTo?: string,
|
||||
sourceId?: number,
|
||||
): Promise<CategoryBreakdownItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -70,6 +72,11 @@ export async function getExpensesByCategory(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
||||
|
|
@ -88,6 +95,56 @@ export async function getExpensesByCategory(
|
|||
);
|
||||
}
|
||||
|
||||
export async function getTransactionsByCategory(
|
||||
categoryId: number | null,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<TransactionRow[]> {
|
||||
const db = await getDb();
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (categoryId === null) {
|
||||
whereClauses.push("t.category_id IS NULL");
|
||||
} else {
|
||||
whereClauses.push(`t.category_id = $${paramIndex}`);
|
||||
params.push(categoryId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClauses.push(`t.date <= $${paramIndex}`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
||||
return db.select<TransactionRow[]>(
|
||||
`SELECT
|
||||
t.id, t.date, t.description, t.amount,
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
c.color AS category_color,
|
||||
s.name AS source_name,
|
||||
t.notes,
|
||||
t.is_manually_categorized
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN import_sources s ON t.source_id = s.id
|
||||
${whereSQL}
|
||||
ORDER BY t.date DESC, t.id DESC`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecentTransactions(
|
||||
limit: number = 10
|
||||
): Promise<RecentTransaction[]> {
|
||||
|
|
|
|||
402
src/services/dataExportService.ts
Normal file
402
src/services/dataExportService.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { getDb } from "./db";
|
||||
import Papa from "papaparse";
|
||||
import type { Category, Supplier, Keyword } from "../shared/types";
|
||||
|
||||
// --- Export types ---
|
||||
|
||||
export type ExportMode =
|
||||
| "transactions_with_categories"
|
||||
| "transactions_only"
|
||||
| "categories_only";
|
||||
|
||||
export type ExportFormat = "json" | "csv";
|
||||
|
||||
export interface ExportEnvelope {
|
||||
export_type: ExportMode;
|
||||
app_version: string;
|
||||
exported_at: string;
|
||||
data: {
|
||||
categories?: Category[];
|
||||
suppliers?: Supplier[];
|
||||
keywords?: Keyword[];
|
||||
transactions?: ExportTransaction[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportTransaction {
|
||||
id: number;
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
original_description: string | null;
|
||||
notes: string | null;
|
||||
is_manually_categorized: number;
|
||||
is_split: number;
|
||||
parent_transaction_id: number | null;
|
||||
}
|
||||
|
||||
// --- Import types ---
|
||||
|
||||
export interface ImportSummary {
|
||||
type: ExportMode;
|
||||
categoriesCount: number;
|
||||
suppliersCount: number;
|
||||
keywordsCount: number;
|
||||
transactionsCount: number;
|
||||
}
|
||||
|
||||
// --- Data gathering ---
|
||||
|
||||
export async function getExportCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>("SELECT * FROM categories ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportSuppliers(): Promise<Supplier[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Supplier[]>("SELECT * FROM suppliers ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportKeywords(): Promise<Keyword[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Keyword[]>("SELECT * FROM keywords ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportTransactions(): Promise<ExportTransaction[]> {
|
||||
const db = await getDb();
|
||||
return db.select<ExportTransaction[]>(
|
||||
`SELECT t.id, t.date, t.description, t.amount, t.category_id,
|
||||
c.name AS category_name, t.original_description, t.notes,
|
||||
t.is_manually_categorized, t.is_split, t.parent_transaction_id
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
ORDER BY t.date, t.id`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Serialization ---
|
||||
|
||||
export function serializeToJson(
|
||||
exportType: ExportMode,
|
||||
data: ExportEnvelope["data"],
|
||||
appVersion: string
|
||||
): string {
|
||||
const envelope: ExportEnvelope = {
|
||||
export_type: exportType,
|
||||
app_version: appVersion,
|
||||
exported_at: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
return JSON.stringify(envelope, null, 2);
|
||||
}
|
||||
|
||||
export function serializeTransactionsToCsv(
|
||||
transactions: ExportTransaction[]
|
||||
): string {
|
||||
return Papa.unparse(
|
||||
transactions.map((t) => ({
|
||||
date: t.date,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
category_name: t.category_name ?? "",
|
||||
category_id: t.category_id ?? "",
|
||||
original_description: t.original_description ?? "",
|
||||
notes: t.notes ?? "",
|
||||
is_manually_categorized: t.is_manually_categorized,
|
||||
is_split: t.is_split,
|
||||
parent_transaction_id: t.parent_transaction_id ?? "",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Import parsing ---
|
||||
|
||||
export function parseImportedJson(content: string): {
|
||||
envelope: ExportEnvelope;
|
||||
summary: ImportSummary;
|
||||
} {
|
||||
let envelope: ExportEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON file");
|
||||
}
|
||||
|
||||
if (
|
||||
!envelope.export_type ||
|
||||
!envelope.data ||
|
||||
typeof envelope.data !== "object"
|
||||
) {
|
||||
throw new Error("Invalid export file format — missing required fields");
|
||||
}
|
||||
|
||||
const validTypes: ExportMode[] = [
|
||||
"transactions_with_categories",
|
||||
"transactions_only",
|
||||
"categories_only",
|
||||
];
|
||||
if (!validTypes.includes(envelope.export_type)) {
|
||||
throw new Error(`Unknown export type: ${envelope.export_type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
envelope,
|
||||
summary: {
|
||||
type: envelope.export_type,
|
||||
categoriesCount: envelope.data.categories?.length ?? 0,
|
||||
suppliersCount: envelope.data.suppliers?.length ?? 0,
|
||||
keywordsCount: envelope.data.keywords?.length ?? 0,
|
||||
transactionsCount: envelope.data.transactions?.length ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseImportedCsv(content: string): {
|
||||
transactions: ExportTransaction[];
|
||||
summary: ImportSummary;
|
||||
} {
|
||||
const result = Papa.parse<Record<string, string>>(content, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
if (result.errors.length > 0 && result.data.length === 0) {
|
||||
throw new Error(`CSV parse error: ${result.errors[0].message}`);
|
||||
}
|
||||
|
||||
const transactions: ExportTransaction[] = result.data.map((row, i) => ({
|
||||
id: i,
|
||||
date: row.date ?? "",
|
||||
description: row.description ?? "",
|
||||
amount: parseFloat(row.amount) || 0,
|
||||
category_id: row.category_id ? parseInt(row.category_id) : null,
|
||||
category_name: row.category_name || null,
|
||||
original_description: row.original_description || null,
|
||||
notes: row.notes || null,
|
||||
is_manually_categorized: parseInt(row.is_manually_categorized) || 0,
|
||||
is_split: parseInt(row.is_split) || 0,
|
||||
parent_transaction_id: row.parent_transaction_id
|
||||
? parseInt(row.parent_transaction_id)
|
||||
: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
transactions,
|
||||
summary: {
|
||||
type: "transactions_only",
|
||||
categoriesCount: 0,
|
||||
suppliersCount: 0,
|
||||
keywordsCount: 0,
|
||||
transactionsCount: transactions.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Import execution ---
|
||||
|
||||
export async function importCategoriesOnly(data: ExportEnvelope["data"]): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
// Wipe keywords, suppliers, categories
|
||||
await db.execute("DELETE FROM keywords");
|
||||
await db.execute("DELETE FROM suppliers");
|
||||
await db.execute("DELETE FROM categories");
|
||||
|
||||
// Nullify category/supplier references on transactions
|
||||
await db.execute(
|
||||
"UPDATE transactions SET category_id = NULL, supplier_id = NULL, is_manually_categorized = 0"
|
||||
);
|
||||
|
||||
// Re-insert categories
|
||||
if (data.categories) {
|
||||
for (const cat of data.categories) {
|
||||
await db.execute(
|
||||
`INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
cat.id,
|
||||
cat.name,
|
||||
cat.parent_id ?? null,
|
||||
cat.color ?? null,
|
||||
cat.icon ?? null,
|
||||
cat.type,
|
||||
cat.is_active ? 1 : 0,
|
||||
cat.is_inputable ? 1 : 0,
|
||||
cat.sort_order,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-insert suppliers
|
||||
if (data.suppliers) {
|
||||
for (const sup of data.suppliers) {
|
||||
await db.execute(
|
||||
`INSERT INTO suppliers (id, name, normalized_name, category_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-insert keywords
|
||||
if (data.keywords) {
|
||||
for (const kw of data.keywords) {
|
||||
await db.execute(
|
||||
`INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function importTransactionsWithCategories(
|
||||
data: ExportEnvelope["data"],
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
// Wipe everything
|
||||
await db.execute("DELETE FROM transactions");
|
||||
await db.execute("DELETE FROM imported_files");
|
||||
await db.execute("DELETE FROM import_sources");
|
||||
await db.execute("DELETE FROM keywords");
|
||||
await db.execute("DELETE FROM suppliers");
|
||||
await db.execute("DELETE FROM categories");
|
||||
|
||||
// Re-insert categories
|
||||
if (data.categories) {
|
||||
for (const cat of data.categories) {
|
||||
await db.execute(
|
||||
`INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
cat.id,
|
||||
cat.name,
|
||||
cat.parent_id ?? null,
|
||||
cat.color ?? null,
|
||||
cat.icon ?? null,
|
||||
cat.type,
|
||||
cat.is_active ? 1 : 0,
|
||||
cat.is_inputable ? 1 : 0,
|
||||
cat.sort_order,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-insert suppliers
|
||||
if (data.suppliers) {
|
||||
for (const sup of data.suppliers) {
|
||||
await db.execute(
|
||||
`INSERT INTO suppliers (id, name, normalized_name, category_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-insert keywords
|
||||
if (data.keywords) {
|
||||
for (const kw of data.keywords) {
|
||||
await db.execute(
|
||||
`INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create tracking records for import history
|
||||
const sourceResult = await db.execute(
|
||||
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
|
||||
);
|
||||
const sourceId = sourceResult.lastInsertId;
|
||||
|
||||
const txCount = data.transactions?.length ?? 0;
|
||||
const fileResult = await db.execute(
|
||||
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
|
||||
);
|
||||
const fileId = fileResult.lastInsertId;
|
||||
|
||||
// Re-insert transactions linked to the import
|
||||
if (data.transactions) {
|
||||
for (const tx of data.transactions) {
|
||||
await db.execute(
|
||||
`INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id, source_id, file_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
tx.date,
|
||||
tx.description,
|
||||
tx.amount,
|
||||
tx.category_id,
|
||||
tx.original_description,
|
||||
tx.notes,
|
||||
tx.is_manually_categorized,
|
||||
tx.is_split,
|
||||
tx.parent_transaction_id,
|
||||
sourceId,
|
||||
fileId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function importTransactionsOnly(
|
||||
data: ExportEnvelope["data"],
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
// Wipe transactions and import history
|
||||
await db.execute("DELETE FROM transactions");
|
||||
await db.execute("DELETE FROM imported_files");
|
||||
await db.execute("DELETE FROM import_sources");
|
||||
|
||||
// Create tracking records for import history
|
||||
const sourceResult = await db.execute(
|
||||
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
|
||||
);
|
||||
const sourceId = sourceResult.lastInsertId;
|
||||
|
||||
const txCount = data.transactions?.length ?? 0;
|
||||
const fileResult = await db.execute(
|
||||
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
|
||||
);
|
||||
const fileId = fileResult.lastInsertId;
|
||||
|
||||
// Re-insert transactions linked to the import
|
||||
if (data.transactions) {
|
||||
for (const tx of data.transactions) {
|
||||
await db.execute(
|
||||
`INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id, source_id, file_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
tx.date,
|
||||
tx.description,
|
||||
tx.amount,
|
||||
tx.category_id,
|
||||
tx.original_description,
|
||||
tx.notes,
|
||||
tx.is_manually_categorized,
|
||||
tx.is_split,
|
||||
tx.parent_transaction_id,
|
||||
sourceId,
|
||||
fileId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,46 @@
|
|||
import Database from "@tauri-apps/plugin-sql";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
|
||||
export async function getDb(): Promise<Database> {
|
||||
if (!dbInstance) {
|
||||
dbInstance = await Database.load("sqlite:simpl_resultat.db");
|
||||
throw new Error("No database connection. Call connectToProfile() first.");
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export async function connectToProfile(dbFilename: string): Promise<void> {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
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}`);
|
||||
}
|
||||
|
||||
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||
for (const sql of sqlStatements) {
|
||||
await dbInstance.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue