T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search
- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form, SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults) - 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet, TopBar with SearchDropdown) - 13 new routes in routes/index.tsx - i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys - 12 new test files + 2 existing test fixes (TopBar, AppShell) - 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard, ActivityFeed, UnsavedChangesGuard) - 16 new API hooks in hooks.ts - Verification: 141 tests pass, build succeeds, tsc --noEmit clean
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useContact, useCreateContact, useUpdateContact, useCompanies, Company } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
|
||||
|
||||
const contactSchema = z.object({
|
||||
first_name: z.string().min(1, 'Vorname ist erforderlich').max(100),
|
||||
last_name: z.string().min(1, 'Nachname ist erforderlich').max(100),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
|
||||
phone: z.string().max(30).optional().or(z.literal('')),
|
||||
position: z.string().max(100).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type ContactFormValues = z.infer<typeof contactSchema>;
|
||||
|
||||
export function ContactFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { data: contact, isLoading } = useContact(isEdit ? id : undefined);
|
||||
const createMutation = useCreateContact();
|
||||
const updateMutation = useUpdateContact();
|
||||
const { data: companiesData } = useCompanies(1, 100);
|
||||
const [selectedCompanyIds, setSelectedCompanyIds] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
position: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && contact) {
|
||||
reset({
|
||||
first_name: contact.first_name || '',
|
||||
last_name: contact.last_name || '',
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
position: contact.position || '',
|
||||
});
|
||||
setSelectedCompanyIds(contact.company_ids || (contact.companies?.map((c) => c.id) || []));
|
||||
}
|
||||
}, [contact, isEdit, reset]);
|
||||
|
||||
const toggleCompany = (companyId: string) => {
|
||||
setSelectedCompanyIds((prev) =>
|
||||
prev.includes(companyId)
|
||||
? prev.filter((cid) => cid !== companyId)
|
||||
: [...prev, companyId]
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: ContactFormValues) => {
|
||||
const payload = { ...values, company_ids: selectedCompanyIds };
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateMutation.mutateAsync({ id, data: payload });
|
||||
toast.success(t('contacts.updated'));
|
||||
navigate(`/contacts/${id}`);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(payload) as { id: string };
|
||||
toast.success(t('contacts.created'));
|
||||
navigate(`/contacts/${result.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableCompanies: Company[] = companiesData?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<UnsavedChangesGuard isDirty={isDirty || selectedCompanyIds.length > 0} />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{isEdit ? t('contacts.edit') : t('contacts.create')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('contacts.firstName')}>
|
||||
<Input
|
||||
{...register('first_name')}
|
||||
label={t('contacts.firstName')}
|
||||
required
|
||||
error={errors.first_name?.message}
|
||||
placeholder="Max"
|
||||
data-testid="contact-first-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.lastName')}>
|
||||
<Input
|
||||
{...register('last_name')}
|
||||
label={t('contacts.lastName')}
|
||||
required
|
||||
error={errors.last_name?.message}
|
||||
placeholder="Mustermann"
|
||||
data-testid="contact-last-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.email')}>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('contacts.email')}
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
placeholder="max@mustermann.de"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.phone')}>
|
||||
<Input
|
||||
{...register('phone')}
|
||||
label={t('contacts.phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="+49 170 1234567"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title={t('contacts.position')}>
|
||||
<Input
|
||||
{...register('position')}
|
||||
label={t('contacts.position')}
|
||||
error={errors.position?.message}
|
||||
placeholder="Geschäftsführer"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('contacts.assignCompanies')}>
|
||||
{availableCompanies.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('companies.emptyTitle')}</p>
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="company-assignment-list">
|
||||
{availableCompanies.map((company) => (
|
||||
<label
|
||||
key={company.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCompanyIds.includes(company.id)}
|
||||
onChange={() => toggleCompany(company.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={company.name}
|
||||
/>
|
||||
<span className="text-sm text-secondary-900">{company.name}</span>
|
||||
{company.industry && (
|
||||
<Badge variant="primary">{company.industry}</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedCompanyIds.length > 0 && (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{selectedCompanyIds.length} {selectedCompanyIds.length === 1 ? 'Firma zugewiesen' : 'Firmen zugewiesen'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} data-testid="contact-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user