Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Recipes from './pages/Recipes';
import MealPlanning from './pages/MealPlanning';
import Budget from './pages/Budget';
import Family from './pages/Family';
import Settings from './pages/Settings';

function App() {
const { isAuthenticated, loading } = useAuth();
Expand Down Expand Up @@ -42,6 +43,7 @@ function App() {
<Route path="/meal-planning" element={<MealPlanning />} />
<Route path="/budget" element={<Budget />} />
<Route path="/family" element={<Family />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
UtensilsCrossed,
Wallet,
Users,
Settings,
Moon,
Sun,
LogOut,
Expand All @@ -36,6 +37,7 @@ const navigation = [
{ name: 'Repas', href: '/meal-planning', icon: UtensilsCrossed },
{ name: 'Budget', href: '/budget', icon: Wallet },
{ name: 'Famille', href: '/family', icon: Users },
{ name: 'Paramètres', href: '/settings', icon: Settings },
];

const mobileTabs = [
Expand Down
212 changes: 212 additions & 0 deletions client/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React, { useRef, useState } from 'react';
import { api } from '../lib/api';
import { Download, Upload, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardContent, Button } from '../components/ui';

interface ImportCounts {
family_members?: number;
tasks?: number;
recipes?: number;
meal_plans?: number;
budget_entries?: number;
budget_limits?: number;
shopping_items?: number;
appointments?: number;
schedule_entries?: number;
}

const ENTITY_LABELS: Record<string, string> = {
family_members: 'Membres de la famille',
tasks: 'Tâches',
recipes: 'Recettes',
meal_plans: 'Repas planifiés',
budget_entries: 'Entrées budget',
budget_limits: 'Limites budget',
shopping_items: 'Articles de courses',
appointments: 'Rendez-vous',
schedule_entries: 'Plannings',
};

const Settings: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState('');
const [importLoading, setImportLoading] = useState(false);
const [importError, setImportError] = useState('');
const [importSuccess, setImportSuccess] = useState<ImportCounts | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);

const handleExport = async () => {
setExportLoading(true);
setExportError('');
try {
const response = await api.get<{ success: boolean; data: unknown }>('/api/data/export');
const blob = new Blob([JSON.stringify(response.data, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `openfamily-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
setExportError(error instanceof Error ? error.message : 'Erreur lors de l\'export.');
} finally {
setExportLoading(false);
}
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
setImportError('');
setImportSuccess(null);
};

const handleImport = async () => {
if (!selectedFile) return;
setImportLoading(true);
setImportError('');
setImportSuccess(null);
try {
const text = await selectedFile.text();
const parsed = JSON.parse(text);

// Accept both the raw export format and the full API response
const data = parsed.success && parsed.data ? parsed.data : parsed;

const response = await api.post<{ success: boolean; data: { imported: ImportCounts } }>(
'/api/data/import',
data
);
if (response.success) {
setImportSuccess(response.data.imported);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
} catch (error) {
if (error instanceof SyntaxError) {
setImportError('Fichier JSON invalide.');
} else {
setImportError(error instanceof Error ? error.message : 'Erreur lors de l\'import.');
}
} finally {
setImportLoading(false);
}
};

return (
<div className="space-y-6">
<div>
<h2 className="text-title font-bold text-foreground">Paramètres</h2>
<p className="text-caption text-muted-foreground">Gérez vos données et préférences.</p>
</div>

{/* Export */}
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-card bg-primary-soft text-primary">
<Download className="h-5 w-5" />
</div>
<div className="flex-1">
<h3 className="text-caption font-semibold text-foreground">Exporter les données</h3>
<p className="mt-1 text-micro text-muted-foreground">
Télécharge toutes vos données (budget, tâches, recettes, membres, courses,
rendez-vous, plannings, repas) dans un fichier JSON.
</p>
{exportError && (
<p className="mt-2 flex items-center gap-1 text-micro text-destructive">
<AlertCircle className="h-4 w-4" />
{exportError}
</p>
)}
<Button
className="mt-4"
onClick={handleExport}
disabled={exportLoading}
>
{exportLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{exportLoading ? 'Export en cours…' : 'Exporter'}
</Button>
</div>
</div>
</CardContent>
</Card>

{/* Import */}
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-card bg-primary-soft text-primary">
<Upload className="h-5 w-5" />
</div>
<div className="flex-1">
<h3 className="text-caption font-semibold text-foreground">Importer des données</h3>
<p className="mt-1 text-micro text-muted-foreground">
Restaure des données depuis un fichier d'export OpenFamily. Les données
existantes ne sont pas écrasées (doublons ignorés).
</p>

{importSuccess && (
<div className="mt-3 rounded-input border border-border bg-surface-2 p-3">
<p className="mb-2 flex items-center gap-1 text-micro font-semibold text-foreground">
<CheckCircle className="h-4 w-4 text-green-500" />
Import réussi
</p>
<ul className="space-y-0.5 text-micro text-muted-foreground">
{Object.entries(importSuccess).map(([key, count]) => (
<li key={key}>
{ENTITY_LABELS[key] ?? key} : <span className="font-medium text-foreground">{count}</span> élément(s) importé(s)
</li>
))}
</ul>
</div>
)}

{importError && (
<p className="mt-2 flex items-center gap-1 text-micro text-destructive">
<AlertCircle className="h-4 w-4" />
{importError}
</p>
)}

<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="cursor-pointer">
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="sr-only"
onChange={handleFileChange}
/>
<span className="inline-flex h-9 items-center gap-2 rounded-input border border-border bg-card px-3 text-caption font-medium text-foreground hover:bg-surface-2 transition-colors duration-fast">
<Upload className="h-4 w-4" />
{selectedFile ? selectedFile.name : 'Choisir un fichier…'}
</span>
</label>
{selectedFile && (
<Button onClick={handleImport} disabled={importLoading}>
{importLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{importLoading ? 'Import en cours…' : 'Importer'}
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

export default Settings;
2 changes: 2 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import budgetRoutes from './routes/budget';
import familyRoutes from './routes/family';
import dashboardRoutes from './routes/dashboard';
import planningRoutes from './routes/planning';
import dataTransferRoutes from './routes/dataTransfer';
import { loadEnv } from './config/loadEnv';

loadEnv();
Expand Down Expand Up @@ -47,6 +48,7 @@ app.use('/api/budget', budgetRoutes);
app.use('/api/family', familyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/planning', planningRoutes);
app.use('/api/data', dataTransferRoutes);

// 404 handler
app.use((req, res) => {
Expand Down
113 changes: 113 additions & 0 deletions server/src/routes/dataTransfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Router } from 'express';
import { getClient, query } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';

const router = Router();
router.use(authMiddleware);

// Export all user data
router.get('/export', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;

const [
familyMembers,
tasks,
recipes,
mealPlans,
budgetEntries,
budgetLimits,
shoppingItems,
appointments,
scheduleEntries,
] = await Promise.all([
query('SELECT * FROM family_members WHERE user_id = $1', [userId]),
query('SELECT * FROM tasks WHERE user_id = $1', [userId]),
query('SELECT * FROM recipes WHERE user_id = $1', [userId]),
query('SELECT * FROM meal_plans WHERE user_id = $1', [userId]),
query('SELECT * FROM budget_entries WHERE user_id = $1', [userId]),
query('SELECT * FROM budget_limits WHERE user_id = $1', [userId]),
query('SELECT * FROM shopping_items WHERE user_id = $1', [userId]),
query('SELECT * FROM appointments WHERE user_id = $1', [userId]),
query('SELECT * FROM schedule_entries WHERE user_id = $1', [userId]),
]);

const exportData = {
version: '1.0',
exportedAt: new Date().toISOString(),
family_members: familyMembers.rows,
tasks: tasks.rows,
recipes: recipes.rows,
meal_plans: mealPlans.rows,
budget_entries: budgetEntries.rows,
budget_limits: budgetLimits.rows,
shopping_items: shoppingItems.rows,
appointments: appointments.rows,
schedule_entries: scheduleEntries.rows,
};

res.json({ success: true, data: exportData });
} catch (error) {
console.error('Export error:', error);
res.status(500).json({ success: false, error: 'Internal server error' });
}
});

// Import user data
router.post('/import', async (req: AuthRequest, res) => {
const userId = req.userId!;
const importData = req.body;

if (!importData || typeof importData !== 'object') {
return res.status(400).json({ success: false, error: 'Invalid import data format' });
}

const client = await getClient();
const counts: Record<string, number> = {};

const importRows = async (table: string, rows: unknown) => {
if (!Array.isArray(rows) || rows.length === 0) return;
let count = 0;
for (const row of rows) {
const entry: Record<string, unknown> = { ...(row as Record<string, unknown>), user_id: userId };
const keys = Object.keys(entry);
const values = keys.map((k) => entry[k]);
const placeholders = keys.map((_, i) => `$${i + 1}`);
const result = await client.query(
`INSERT INTO ${table} (${keys.map((k) => `"${k}"`).join(', ')})
VALUES (${placeholders.join(', ')})
ON CONFLICT DO NOTHING`,
values
);
count += result.rowCount ?? 0;
}
counts[table] = count;
};

try {
await client.query('BEGIN');

// Import in order respecting foreign key constraints:
// family_members and recipes must come before tables that reference them
await importRows('family_members', importData.family_members);
await importRows('recipes', importData.recipes);
await importRows('tasks', importData.tasks);
await importRows('budget_entries', importData.budget_entries);
await importRows('budget_limits', importData.budget_limits);
await importRows('shopping_items', importData.shopping_items);
await importRows('appointments', importData.appointments);
await importRows('schedule_entries', importData.schedule_entries);
await importRows('meal_plans', importData.meal_plans);

await client.query('COMMIT');
res.json({ success: true, data: { imported: counts } });
} catch (error) {
await client.query('ROLLBACK');
console.error('Import error:', error);
res.status(500).json({ success: false, error: 'Import failed. No data was modified.' });
} finally {
client.release();
}
});

export default router;