Compare commits
10 Commits
7b7554c84b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 60288b9b3a | |||
|
|
19abdd0e88 | ||
| 061bc18df7 | |||
|
|
ecf0240ba9 | ||
| 90127d1e0d | |||
|
|
8daea3ea47 | ||
| 318e2144f0 | |||
|
|
7c352d9e82 | ||
| fe1de2bbf9 | |||
|
|
035e6b20c7 |
16
.github/workflows/docker-publish.yml
vendored
16
.github/workflows/docker-publish.yml
vendored
@@ -13,22 +13,22 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 192.168.31.100:3847 -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.arreliny.dedyn.io -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||||
|
|
||||||
- name: Build and push backend image
|
- name: Build and push backend image
|
||||||
run: |
|
run: |
|
||||||
docker build -t 192.168.31.100:3847/arrelin/family_budget-backend:latest -t 192.168.31.100:3847/arrelin/family_budget-backend:${{ gitea.sha }} ./backend
|
docker build -t gitea.arreliny.dedyn.io/arrelin/family_budget-backend:latest -t gitea.arreliny.dedyn.io/arrelin/family_budget-backend:${{ gitea.sha }} ./backend
|
||||||
docker push 192.168.31.100:3847/arrelin/family_budget-backend:latest
|
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-backend:latest
|
||||||
docker push 192.168.31.100:3847/arrelin/family_budget-backend:${{ gitea.sha }}
|
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-backend:${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Build and push frontend image
|
- name: Build and push frontend image
|
||||||
run: |
|
run: |
|
||||||
docker build -t 192.168.31.100:3847/arrelin/family_budget-frontend:latest -t 192.168.31.100:3847/arrelin/family_budget-frontend:${{ gitea.sha }} ./frontend
|
docker build -t gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:latest -t gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:${{ gitea.sha }} ./frontend
|
||||||
docker push 192.168.31.100:3847/arrelin/family_budget-frontend:latest
|
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:latest
|
||||||
docker push 192.168.31.100:3847/arrelin/family_budget-frontend:${{ gitea.sha }}
|
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Logout
|
- name: Logout
|
||||||
run: docker logout 192.168.31.100:3847
|
run: docker logout gitea.arreliny.dedyn.io
|
||||||
|
|
||||||
- name: Trigger Coolify redeploy
|
- name: Trigger Coolify redeploy
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ rand = "0.9.2"
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -231,7 +231,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
||||||
|
|
||||||
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
|
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
|
||||||
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string());
|
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080,http://localhost:1420,http://tauri.localhost,https://tauri.localhost".to_string());
|
||||||
|
|
||||||
let origins: Vec<HeaderValue> = allowed_origins
|
let origins: Vec<HeaderValue> = allowed_origins
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ use sea_orm::DbErr;
|
|||||||
use sea_orm_migration::prelude::*;
|
use sea_orm_migration::prelude::*;
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), DbErr> {
|
async fn main() -> Result<(), DbErr> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "family_budget=debug,info".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
let db = establish_connection().await?;
|
let db = establish_connection().await?;
|
||||||
println!("Successfully connected to database!");
|
println!("Successfully connected to database!");
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use sea_orm::{DatabaseConnection, EntityTrait};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
use tracing::{info, warn};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
@@ -116,6 +117,7 @@ pub async fn google_auth(
|
|||||||
let nonce = uuid::Uuid::new_v4().to_string();
|
let nonce = uuid::Uuid::new_v4().to_string();
|
||||||
let mobile_state = make_mobile_csrf_state(&nonce);
|
let mobile_state = make_mobile_csrf_state(&nonce);
|
||||||
let auth_url = oauth_service.get_auth_url_with_state(mobile_state);
|
let auth_url = oauth_service.get_auth_url_with_state(mobile_state);
|
||||||
|
info!("mobile google_auth: generated signed state for nonce={}", nonce);
|
||||||
return Ok(Json(OAuthUrlResponse { url: auth_url }));
|
return Ok(Json(OAuthUrlResponse { url: auth_url }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ pub async fn google_callback(
|
|||||||
Query(query): Query<GoogleCallbackQuery>,
|
Query(query): Query<GoogleCallbackQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let is_mobile = verify_mobile_csrf_state(&query.state);
|
let is_mobile = verify_mobile_csrf_state(&query.state);
|
||||||
|
info!("google_callback: state={} is_mobile={}", &query.state[..query.state.len().min(20)], is_mobile);
|
||||||
|
|
||||||
if !is_mobile {
|
if !is_mobile {
|
||||||
let session_csrf: Option<String> = session
|
let session_csrf: Option<String> = session
|
||||||
@@ -162,7 +165,10 @@ pub async fn google_callback(
|
|||||||
|
|
||||||
match session_csrf {
|
match session_csrf {
|
||||||
Some(csrf) if csrf == query.state => {}
|
Some(csrf) if csrf == query.state => {}
|
||||||
_ => return Err(StatusCode::UNAUTHORIZED),
|
_ => {
|
||||||
|
warn!("google_callback: CSRF mismatch, session_csrf={:?}", session_csrf.as_deref().map(|s| &s[..s.len().min(10)]));
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +197,7 @@ pub async fn google_callback(
|
|||||||
|
|
||||||
if is_mobile {
|
if is_mobile {
|
||||||
let token = make_auth_token(user.id);
|
let token = make_auth_token(user.id);
|
||||||
|
info!("google_callback: mobile auth for user_id={}, token_prefix={}", user.id, &token[..token.len().min(20)]);
|
||||||
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
|
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
|
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
|
||||||
@@ -231,7 +238,15 @@ pub async fn mobile_callback(
|
|||||||
State(db): State<DatabaseConnection>,
|
State(db): State<DatabaseConnection>,
|
||||||
Query(query): Query<MobileCallbackQuery>,
|
Query(query): Query<MobileCallbackQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
let user_id = verify_auth_token(&query.token).ok_or(StatusCode::UNAUTHORIZED)?;
|
info!("mobile_callback: received token_prefix={}", &query.token[..query.token.len().min(20)]);
|
||||||
|
let user_id = match verify_auth_token(&query.token) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
warn!("mobile_callback: token verification failed for token={}", &query.token[..query.token.len().min(40)]);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("mobile_callback: token valid for user_id={}", user_id);
|
||||||
|
|
||||||
let user = User::find_by_id(user_id)
|
let user = User::find_by_id(user_id)
|
||||||
.one(&db)
|
.one(&db)
|
||||||
|
|||||||
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
@@ -345,6 +348,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -4099,6 +4155,12 @@
|
|||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
"addCategory": "Add category",
|
"addCategory": "Add category",
|
||||||
"deleteConfirm": "Delete category?",
|
"deleteConfirm": "Delete category?",
|
||||||
"resetConfirm": "Delete all expenses for this category?",
|
"resetConfirm": "Delete all expenses for this category?",
|
||||||
|
"editTitle": "Category settings",
|
||||||
|
"editError": "Error updating category",
|
||||||
"createError": "Error creating category",
|
"createError": "Error creating category",
|
||||||
"deleteError": "Error deleting category",
|
"deleteError": "Error deleting category",
|
||||||
"resetError": "Error resetting expenses"
|
"resetError": "Error resetting expenses"
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
"addCategory": "Добавить категорию",
|
"addCategory": "Добавить категорию",
|
||||||
"deleteConfirm": "Удалить категорию?",
|
"deleteConfirm": "Удалить категорию?",
|
||||||
"resetConfirm": "Удалить все траты по этой категории?",
|
"resetConfirm": "Удалить все траты по этой категории?",
|
||||||
|
"editTitle": "Настройки категории",
|
||||||
|
"editError": "Ошибка обновления категории",
|
||||||
"createError": "Ошибка создания категории",
|
"createError": "Ошибка создания категории",
|
||||||
"deleteError": "Ошибка удаления категории",
|
"deleteError": "Ошибка удаления категории",
|
||||||
"resetError": "Ошибка сброса трат"
|
"resetError": "Ошибка сброса трат"
|
||||||
|
|||||||
@@ -23,8 +23,64 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
User,
|
User,
|
||||||
|
Settings,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ShoppingListModal from '../components/ShoppingListModal';
|
import ShoppingListModal from '../components/ShoppingListModal';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
function SortableItem({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
children: (props: { listeners: ReturnType<typeof useSortable>['listeners']; attributes: ReturnType<typeof useSortable>['attributes'] }) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : 'auto' as any,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children({ listeners, attributes })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryOrder = (fid: string): number[] => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`cat_order_${fid}`);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCategoryOrder = (fid: string, order: number[]) => {
|
||||||
|
localStorage.setItem(`cat_order_${fid}`, JSON.stringify(order));
|
||||||
|
};
|
||||||
|
|
||||||
export default function FamilyView() {
|
export default function FamilyView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,6 +88,11 @@ export default function FamilyView() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { selectedFamily } = useStore();
|
const { selectedFamily } = useStore();
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
|
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -45,6 +106,10 @@ export default function FamilyView() {
|
|||||||
const [expenseAmount, setExpenseAmount] = useState('');
|
const [expenseAmount, setExpenseAmount] = useState('');
|
||||||
const [expenseDescription, setExpenseDescription] = useState('');
|
const [expenseDescription, setExpenseDescription] = useState('');
|
||||||
|
|
||||||
|
const [showEditCategory, setShowEditCategory] = useState<number | null>(null);
|
||||||
|
const [editCategoryName, setEditCategoryName] = useState('');
|
||||||
|
const [editCategoryLimit, setEditCategoryLimit] = useState('');
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
const [showArchive, setShowArchive] = useState<number | null>(null);
|
const [showArchive, setShowArchive] = useState<number | null>(null);
|
||||||
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
|
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
|
||||||
@@ -72,7 +137,19 @@ export default function FamilyView() {
|
|||||||
console.log('Loading categories for family:', familyId);
|
console.log('Loading categories for family:', familyId);
|
||||||
const response = await categoryApi.getAllByFamily(parseInt(familyId));
|
const response = await categoryApi.getAllByFamily(parseInt(familyId));
|
||||||
console.log('Categories loaded:', response.data);
|
console.log('Categories loaded:', response.data);
|
||||||
|
const savedOrder = getCategoryOrder(familyId);
|
||||||
|
if (savedOrder.length > 0) {
|
||||||
|
const sorted = [...response.data].sort((a, b) => {
|
||||||
|
const ai = savedOrder.indexOf(a.id);
|
||||||
|
const bi = savedOrder.indexOf(b.id);
|
||||||
|
if (ai === -1) return 1;
|
||||||
|
if (bi === -1) return -1;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
setCategories(sorted);
|
||||||
|
} else {
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
const limits = new Map<number, number>();
|
const limits = new Map<number, number>();
|
||||||
for (const category of response.data) {
|
for (const category of response.data) {
|
||||||
@@ -181,6 +258,41 @@ export default function FamilyView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenEditCategory = (category: Category) => {
|
||||||
|
setEditCategoryName(category.name);
|
||||||
|
setEditCategoryLimit(parseFloat(category.limit_amount.toString()).toString());
|
||||||
|
setShowEditCategory(category.id);
|
||||||
|
setShowAddExpense(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCategory = async (categoryId: number) => {
|
||||||
|
if (!familyId || !editCategoryName || !editCategoryLimit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await categoryApi.update(parseInt(familyId), categoryId, {
|
||||||
|
name: editCategoryName,
|
||||||
|
limit_amount: parseFloat(editCategoryLimit),
|
||||||
|
});
|
||||||
|
setShowEditCategory(null);
|
||||||
|
loadCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || err.message || t('category.editError');
|
||||||
|
alert(`${t('category.editError')}: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id || !familyId) return;
|
||||||
|
setCategories((prev) => {
|
||||||
|
const oldIndex = prev.findIndex(c => c.id === active.id);
|
||||||
|
const newIndex = prev.findIndex(c => c.id === over.id);
|
||||||
|
const reordered = arrayMove(prev, oldIndex, newIndex);
|
||||||
|
saveCategoryOrder(familyId, reordered.map(c => c.id));
|
||||||
|
return reordered;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleShowHistory = async (categoryId: number) => {
|
const handleShowHistory = async (categoryId: number) => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
|
|
||||||
@@ -375,6 +487,8 @@ export default function FamilyView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={categories.map(c => c.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
|
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
const remaining = remainingLimits.get(category.id) || 0;
|
const remaining = remainingLimits.get(category.id) || 0;
|
||||||
@@ -382,12 +496,20 @@ export default function FamilyView() {
|
|||||||
const percentage = getProgressPercentage(remaining, limit);
|
const percentage = getProgressPercentage(remaining, limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SortableItem key={category.id} id={category.id}>
|
||||||
|
{({ listeners, attributes }) => (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
|
||||||
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
|
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
<div className="flex items-center justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 text-gray-400 hover:text-gray-600 touch-none flex-shrink-0"
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GripVertical className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
|
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
|
||||||
<Tag className="w-6 h-6" />
|
<Tag className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +518,15 @@ export default function FamilyView() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddExpense !== category.id && (
|
{showAddExpense !== category.id && showEditCategory !== category.id && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditCategory(category)}
|
||||||
|
className="p-2 bg-gray-100 hover:bg-gray-200 border border-gray-300 text-gray-500 hover:text-gray-700 rounded-xl transition-all duration-300 shadow-sm"
|
||||||
|
title={t('category.editTitle')}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddExpense(category.id)}
|
onClick={() => setShowAddExpense(category.id)}
|
||||||
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
|
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
|
||||||
@@ -405,6 +535,7 @@ export default function FamilyView() {
|
|||||||
<span className="hidden sm:inline">{t('category.addExpense')}</span>
|
<span className="hidden sm:inline">{t('category.addExpense')}</span>
|
||||||
<span className="sm:hidden">{t('category.expense')}</span>
|
<span className="sm:hidden">{t('category.expense')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -511,6 +642,53 @@ export default function FamilyView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEditCategory === category.id && (
|
||||||
|
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
||||||
|
{t('category.editTitle')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('category.categoryName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editCategoryName}
|
||||||
|
onChange={(e) => setEditCategoryName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('category.categoryLimit')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editCategoryLimit}
|
||||||
|
onChange={(e) => setEditCategoryLimit(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateCategory(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditCategory(null)}
|
||||||
|
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showHistory === category.id && historyData && (
|
{showHistory === category.id && historyData && (
|
||||||
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200">
|
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -645,9 +823,13 @@ export default function FamilyView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
|
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
|
||||||
<div className="flex items-center justify-center gap-3 mb-8">
|
<div className="flex items-center justify-center gap-3 mb-8">
|
||||||
|
|||||||
@@ -30,18 +30,20 @@ export default function Login() {
|
|||||||
try {
|
try {
|
||||||
token = new URL(url).searchParams.get('token');
|
token = new URL(url).searchParams.get('token');
|
||||||
} catch {
|
} catch {
|
||||||
setError(t('login.error'));
|
setError(t('login.authError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!token) { setError(t('login.error')); return; }
|
if (!token) { setError(t('login.authError')); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await authApi.mobileCallback(token);
|
await authApi.mobileCallback(token);
|
||||||
const me = await authApi.me();
|
const me = await authApi.me();
|
||||||
setUser(me.data);
|
setUser(me.data);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
setError(t('login.error'));
|
const status = err?.response?.status;
|
||||||
|
const msg = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
|
||||||
|
setError(`${status ?? 'network'}: ${msg}`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ export default defineConfig({
|
|||||||
? 'https://family-budget.duckdns.org'
|
? 'https://family-budget.duckdns.org'
|
||||||
: 'http://localhost:8080',
|
: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
if (process.env.TAURI_DEV_HOST) {
|
||||||
|
proxy.on('proxyRes', (proxyRes) => {
|
||||||
|
const cookies = proxyRes.headers['set-cookie'];
|
||||||
|
if (cookies) {
|
||||||
|
proxyRes.headers['set-cookie'] = cookies.map(cookie =>
|
||||||
|
cookie
|
||||||
|
.replace(/;\s*Secure/gi, '')
|
||||||
|
.replace(/;\s*Domain=[^;]*/gi, '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev --prefix ../frontend",
|
"beforeDevCommand": "npm run dev --prefix ../frontend",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"beforeBuildCommand": "npm run build --prefix ../frontend",
|
"beforeBuildCommand": "VITE_API_BASE_URL=https://family-budget.duckdns.org/api npm run build --prefix ../frontend",
|
||||||
"frontendDist": "../frontend/dist"
|
"frontendDist": "../frontend/dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
Reference in New Issue
Block a user