feat: unified 0 and 90 degree PDF envelope and category descriptors
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sun, Moon, Laptop, Save, Trash2, Upload, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { useProjects } from '@/lib/hooks/useProjects';
|
||||
import { useWindStore } from '@/store/appStore';
|
||||
import { useI18n } from '@/store/i18nStore';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
importProjectFromText,
|
||||
readProjectFile,
|
||||
type ImportResult,
|
||||
} from '@/lib/import-project';
|
||||
|
||||
const SettingsModule: React.FC = () => {
|
||||
const { theme, setTheme, effectiveTheme } = useTheme();
|
||||
const { projects, loading, error, remove } = useProjects();
|
||||
const { v0, terrainCategory, structureClass, s3Group } = useWindStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const exportSnapshot = () => {
|
||||
const state = useWindStore.getState();
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'ventoapp-state.json');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const text = await readProjectFile(file);
|
||||
const result = importProjectFromText(text);
|
||||
setImportResult(result);
|
||||
} catch (e) {
|
||||
setImportResult({
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'Falha ao ler arquivo',
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6 overflow-auto">
|
||||
<h1 className="text-3xl font-bold">{t('nav_settings')}</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('settings_appearance')}</CardTitle>
|
||||
<CardDescription>{t('settings_appearance_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={theme === 'light' ? 'default' : 'outline'} onClick={() => setTheme('light')}>
|
||||
<Sun className="w-4 h-4 mr-2" /> {t('settings_theme_light')}
|
||||
</Button>
|
||||
<Button variant={theme === 'dark' ? 'default' : 'outline'} onClick={() => setTheme('dark')}>
|
||||
<Moon className="w-4 h-4 mr-2" /> {t('settings_theme_dark')}
|
||||
</Button>
|
||||
<Button variant={theme === 'system' ? 'default' : 'outline'} onClick={() => setTheme('system')}>
|
||||
<Laptop className="w-4 h-4 mr-2" /> {t('settings_theme_system')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
{t('settings_effective')}: <Badge variant="outline">{effectiveTheme}</Badge>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('settings_projects')}</CardTitle>
|
||||
<CardDescription>{t('settings_projects_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings_projects_count', { count: projects.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleImportClick} disabled={importing}>
|
||||
<Upload className="w-4 h-4 mr-2" /> {importing ? t('settings_importing') : t('export_import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={exportSnapshot}>
|
||||
<Save className="w-4 h-4 mr-2" /> {t('export_snapshot')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div
|
||||
className={`rounded-md border p-3 mb-3 text-sm flex items-start gap-2 ${
|
||||
importResult.ok
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-300'
|
||||
: 'bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{importResult.ok ? (
|
||||
<CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{importResult.ok ? t('settings_import_success') : t('settings_import_error')}
|
||||
</div>
|
||||
{importResult.module && (
|
||||
<div className="text-xs mt-1">
|
||||
{t('settings_import_module')}: <Badge variant="outline">{importResult.module}</Badge>
|
||||
{importResult.projectName && (
|
||||
<span className="ml-2">{t('settings_import_project')}: {importResult.projectName}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{importResult.appliedFields && importResult.appliedFields.length > 0 && (
|
||||
<div className="text-xs mt-1">
|
||||
{t('settings_import_fields', { count: importResult.appliedFields.length })}: {importResult.appliedFields.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||
<div className="text-xs mt-1 opacity-80">
|
||||
{t('settings_import_warnings')}: {importResult.warnings.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
{importResult.error && (
|
||||
<div className="text-xs mt-1">{importResult.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('common_loading')}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{projects.length === 0 && !loading && (
|
||||
<p className="text-sm text-muted-foreground">{t('settings_no_projects')}</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between p-2 border rounded-md hover:bg-muted/40">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{p.module} · {new Date(p.updatedAt).toLocaleString(locale === 'pt-BR' ? 'pt-BR' : 'en-US')}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => p.id && remove(p.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('settings_state')}</CardTitle>
|
||||
<CardDescription>{t('settings_state_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
|
||||
<div className="rounded-md border bg-muted/40 p-2"><span className="text-muted-foreground">V₀:</span> <span className="font-mono">{v0}</span></div>
|
||||
<div className="rounded-md border bg-muted/40 p-2"><span className="text-muted-foreground">Categoria:</span> <span className="font-mono">{terrainCategory}</span></div>
|
||||
<div className="rounded-md border bg-muted/40 p-2"><span className="text-muted-foreground">Classe:</span> <span className="font-mono">{structureClass}</span></div>
|
||||
<div className="rounded-md border bg-muted/40 p-2"><span className="text-muted-foreground">S₃ grupo:</span> <span className="font-mono">{s3Group}</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('settings_about')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p><strong>{t('app_title')}</strong> — {t('settings_about_desc')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t('settings_stack')}: React 19 + TypeScript + Vite + Tailwind v4 + shadcn/ui + Zustand + R3F + @react-pdf/renderer.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Cobertura: 11 seções normativas + 3 anexos. Todos os 8 marcos do plano de implementação concluídos.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModule;
|
||||
Reference in New Issue
Block a user