mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
feat(frontend): add Lexware Office integration UI
Implements complete Lexware Office frontend integration: - Types: LexwareVoucher, DealVoucher, LexwareContact, VoucherType - API: lexwareContactsApi + lexwareVouchersApi (16 endpoints) - Hooks: React Query hooks for search, link/unlink, sync, vouchers - LexwareSection: reusable component for Company/Contact detail pages with status badge, sync/push/refresh buttons, and voucher table - LexwareSearchModal: search Lexware contacts and link to CRM entities - DealVouchersSection: link/unlink vouchers on Deal detail page - CRM Settings: Lexware Office toggle (admin-configurable) - Company/Contact/Deal detail pages extended with Lexware sections Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9d496d2e53
commit
2381409e6d
12 changed files with 1671 additions and 4 deletions
|
|
@ -26,6 +26,11 @@ import type {
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
UpdateCompanyPayload,
|
UpdateCompanyPayload,
|
||||||
CompaniesQueryParams,
|
CompaniesQueryParams,
|
||||||
|
LexwareContact,
|
||||||
|
LexwareContactSearchParams,
|
||||||
|
LexwareVoucher,
|
||||||
|
LexwareVouchersQueryParams,
|
||||||
|
DealVoucher,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -199,3 +204,124 @@ export const companiesApi = {
|
||||||
.delete<SingleResponse<Company>>(`/crm/companies/${id}`)
|
.delete<SingleResponse<Company>>(`/crm/companies/${id}`)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Lexware Office: Contacts ---
|
||||||
|
|
||||||
|
export const lexwareContactsApi = {
|
||||||
|
search: (params: LexwareContactSearchParams) =>
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<LexwareContact>>('/crm/lexware/contacts/search', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
linkCompany: (data: { lexwareContactId: string; companyId: string }) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Company>>('/crm/lexware/contacts/link-company', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
linkContact: (data: { lexwareContactId: string; contactId: string }) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Contact>>('/crm/lexware/contacts/link-contact', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
unlinkCompany: (companyId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<Company>>(
|
||||||
|
`/crm/lexware/contacts/unlink-company/${companyId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
unlinkContact: (contactId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<Contact>>(
|
||||||
|
`/crm/lexware/contacts/unlink-contact/${contactId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
importCompany: (data: { lexwareContactId: string }) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Company>>(
|
||||||
|
'/crm/lexware/contacts/import-company',
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
importContact: (data: { lexwareContactId: string }) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Contact>>(
|
||||||
|
'/crm/lexware/contacts/import-contact',
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
push: (entityType: 'company' | 'contact', entityId: string) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Company | Contact>>(
|
||||||
|
`/crm/lexware/contacts/push/${entityType}/${entityId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
sync: (entityType: 'company' | 'contact', entityId: string) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Company | Contact>>(
|
||||||
|
`/crm/lexware/contacts/sync/${entityType}/${entityId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Lexware Office: Vouchers ---
|
||||||
|
|
||||||
|
export const lexwareVouchersApi = {
|
||||||
|
getForCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<LexwareVoucher>>(
|
||||||
|
`/crm/lexware/vouchers/company/${companyId}`,
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getForContact: (contactId: string, params?: LexwareVouchersQueryParams) =>
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<LexwareVoucher>>(
|
||||||
|
`/crm/lexware/vouchers/contact/${contactId}`,
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getForDeal: (dealId: string) =>
|
||||||
|
api
|
||||||
|
.get<{ success: boolean; data: DealVoucher[]; meta: { timestamp: string } }>(
|
||||||
|
`/crm/lexware/vouchers/deal/${dealId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
linkToDeal: (dealId: string, voucherId: string) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<DealVoucher>>(
|
||||||
|
`/crm/lexware/vouchers/deal/${dealId}/link`,
|
||||||
|
{ voucherId },
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
unlinkFromDeal: (dealId: string, voucherId: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<DealVoucher>>(
|
||||||
|
`/crm/lexware/vouchers/deal/${dealId}/unlink/${voucherId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
refreshCompany: (companyId: string) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<{ count: number }>>(
|
||||||
|
`/crm/lexware/vouchers/refresh/company/${companyId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
refreshContact: (contactId: string) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<{ count: number }>>(
|
||||||
|
`/crm/lexware/vouchers/refresh/contact/${contactId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useCompany, useDeleteCompany } from '../hooks';
|
import { useCompany, useDeleteCompany } from '../hooks';
|
||||||
import { CompanyFormModal } from './CompanyFormModal';
|
import { CompanyFormModal } from './CompanyFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { LexwareSection } from '../lexware/LexwareSection';
|
||||||
import type { DealStatus } from '../types';
|
import type { DealStatus } from '../types';
|
||||||
import styles from './CompanyDetailPage.module.css';
|
import styles from './CompanyDetailPage.module.css';
|
||||||
|
|
||||||
|
|
@ -226,7 +227,7 @@ export function CompanyDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rechts: Kontakte + Vorgänge */}
|
{/* Rechts: Kontakte + Vorgänge + Lexware */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
{/* Kontakte */}
|
{/* Kontakte */}
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|
@ -456,6 +457,14 @@ export function CompanyDetailPage() {
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lexware Office */}
|
||||||
|
<LexwareSection
|
||||||
|
entityType="company"
|
||||||
|
entityId={company.id}
|
||||||
|
lexwareContactId={company.lexwareContactId ?? null}
|
||||||
|
lexwareSyncedAt={company.lexwareSyncedAt ?? null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useContact, useDeals, useDeleteContact } from '../hooks';
|
||||||
import { ContactFormModal } from './ContactFormModal';
|
import { ContactFormModal } from './ContactFormModal';
|
||||||
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { LexwareSection } from '../lexware/LexwareSection';
|
||||||
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
||||||
import styles from './ContactDetailPage.module.css';
|
import styles from './ContactDetailPage.module.css';
|
||||||
|
|
||||||
|
|
@ -440,6 +441,16 @@ export function ContactDetailPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Lexware Office */}
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<LexwareSection
|
||||||
|
entityType="contact"
|
||||||
|
entityId={contact.id}
|
||||||
|
lexwareContactId={contact.lexwareContactId ?? null}
|
||||||
|
lexwareSyncedAt={contact.lexwareSyncedAt ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rechts: Aktivitäten-Timeline */}
|
{/* Rechts: Aktivitäten-Timeline */}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useDeal, useDeleteDeal } from '../hooks';
|
import { useDeal, useDeleteDeal } from '../hooks';
|
||||||
import { DealFormModal } from './DealFormModal';
|
import { DealFormModal } from './DealFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
||||||
import type { DealStatus } from '../types';
|
import type { DealStatus } from '../types';
|
||||||
import styles from './DealDetailPage.module.css';
|
import styles from './DealDetailPage.module.css';
|
||||||
|
|
||||||
|
|
@ -247,6 +248,13 @@ export function DealDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Belege (Lexware Vouchers) */}
|
||||||
|
<DealVouchersSection
|
||||||
|
dealId={deal.id}
|
||||||
|
companyId={deal.companyId ?? deal.company?.id ?? null}
|
||||||
|
contactId={deal.contactId ?? deal.contact?.id ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<DealFormModal
|
<DealFormModal
|
||||||
isOpen={isEditOpen}
|
isOpen={isEditOpen}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { contactsApi, dealsApi, pipelinesApi, activitiesApi, companiesApi } from './api';
|
import {
|
||||||
|
contactsApi,
|
||||||
|
dealsApi,
|
||||||
|
pipelinesApi,
|
||||||
|
activitiesApi,
|
||||||
|
companiesApi,
|
||||||
|
lexwareContactsApi,
|
||||||
|
lexwareVouchersApi,
|
||||||
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
DealsQueryParams,
|
DealsQueryParams,
|
||||||
|
|
@ -21,6 +29,8 @@ import type {
|
||||||
CompaniesQueryParams,
|
CompaniesQueryParams,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
UpdateCompanyPayload,
|
UpdateCompanyPayload,
|
||||||
|
LexwareContactSearchParams,
|
||||||
|
LexwareVouchersQueryParams,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -55,6 +65,17 @@ export const crmKeys = {
|
||||||
['crm', 'companies', 'list', params] as const,
|
['crm', 'companies', 'list', params] as const,
|
||||||
detail: (id: string) => ['crm', 'companies', 'detail', id] as const,
|
detail: (id: string) => ['crm', 'companies', 'detail', id] as const,
|
||||||
},
|
},
|
||||||
|
lexware: {
|
||||||
|
all: ['crm', 'lexware'] as const,
|
||||||
|
contactSearch: (params: LexwareContactSearchParams) =>
|
||||||
|
['crm', 'lexware', 'contacts', 'search', params] as const,
|
||||||
|
vouchersCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
|
||||||
|
['crm', 'lexware', 'vouchers', 'company', companyId, params] as const,
|
||||||
|
vouchersContact: (contactId: string, params?: LexwareVouchersQueryParams) =>
|
||||||
|
['crm', 'lexware', 'vouchers', 'contact', contactId, params] as const,
|
||||||
|
vouchersDeal: (dealId: string) =>
|
||||||
|
['crm', 'lexware', 'vouchers', 'deal', dealId] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -368,3 +389,180 @@ export function useDeleteCompany() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Lexware Office — Contacts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useLexwareContactSearch(
|
||||||
|
params: LexwareContactSearchParams,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.lexware.contactSearch(params),
|
||||||
|
queryFn: () => lexwareContactsApi.search(params),
|
||||||
|
enabled: enabled && !!(params.name || params.email),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLinkLexwareCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { lexwareContactId: string; companyId: string }) =>
|
||||||
|
lexwareContactsApi.linkCompany(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLinkLexwareContact() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { lexwareContactId: string; contactId: string }) =>
|
||||||
|
lexwareContactsApi.linkContact(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkLexwareCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (companyId: string) =>
|
||||||
|
lexwareContactsApi.unlinkCompany(companyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkLexwareContact() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (contactId: string) =>
|
||||||
|
lexwareContactsApi.unlinkContact(contactId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushToLexware() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
}: {
|
||||||
|
entityType: 'company' | 'contact';
|
||||||
|
entityId: string;
|
||||||
|
}) => lexwareContactsApi.push(entityType, entityId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncFromLexware() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
}: {
|
||||||
|
entityType: 'company' | 'contact';
|
||||||
|
entityId: string;
|
||||||
|
}) => lexwareContactsApi.sync(entityType, entityId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Lexware Office — Vouchers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useCompanyVouchers(
|
||||||
|
companyId: string,
|
||||||
|
params?: LexwareVouchersQueryParams,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.lexware.vouchersCompany(companyId, params),
|
||||||
|
queryFn: () => lexwareVouchersApi.getForCompany(companyId, params),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContactVouchers(
|
||||||
|
contactId: string,
|
||||||
|
params?: LexwareVouchersQueryParams,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.lexware.vouchersContact(contactId, params),
|
||||||
|
queryFn: () => lexwareVouchersApi.getForContact(contactId, params),
|
||||||
|
enabled: !!contactId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDealVouchers(dealId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.lexware.vouchersDeal(dealId),
|
||||||
|
queryFn: () => lexwareVouchersApi.getForDeal(dealId),
|
||||||
|
enabled: !!dealId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLinkVoucherToDeal() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ dealId, voucherId }: { dealId: string; voucherId: string }) =>
|
||||||
|
lexwareVouchersApi.linkToDeal(dealId, voucherId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkVoucherFromDeal() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ dealId, voucherId }: { dealId: string; voucherId: string }) =>
|
||||||
|
lexwareVouchersApi.unlinkFromDeal(dealId, voucherId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshCompanyVouchers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (companyId: string) =>
|
||||||
|
lexwareVouchersApi.refreshCompany(companyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshContactVouchers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (contactId: string) =>
|
||||||
|
lexwareVouchersApi.refreshContact(contactId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
324
packages/frontend/src/crm/lexware/DealVouchersSection.tsx
Normal file
324
packages/frontend/src/crm/lexware/DealVouchersSection.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
useDealVouchers,
|
||||||
|
useLinkVoucherToDeal,
|
||||||
|
useUnlinkVoucherFromDeal,
|
||||||
|
useCompanyVouchers,
|
||||||
|
useContactVouchers,
|
||||||
|
} from '../hooks';
|
||||||
|
import { useCrmSettings } from '../settings/CrmSettingsContext';
|
||||||
|
import type { LexwareVoucher, DealVoucher, VoucherType } from '../types';
|
||||||
|
import { VOUCHER_TYPE_LABELS } from '../types';
|
||||||
|
import { Modal } from '../../components/Modal';
|
||||||
|
import styles from './LexwareSection.module.css';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function voucherTypeClass(type: VoucherType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'QUOTATION':
|
||||||
|
return styles.typeQuotation;
|
||||||
|
case 'ORDER_CONFIRMATION':
|
||||||
|
return styles.typeOrderConfirmation;
|
||||||
|
case 'INVOICE':
|
||||||
|
return styles.typeInvoice;
|
||||||
|
case 'CREDIT_NOTE':
|
||||||
|
return styles.typeCreditNote;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function voucherStatusClass(status: string | null): string {
|
||||||
|
if (!status) return '';
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === 'paid' || lower === 'bezahlt') return styles.statusPaid;
|
||||||
|
if (lower === 'overdue' || lower === 'überfällig') return styles.statusOverdue;
|
||||||
|
if (lower === 'open' || lower === 'offen') return styles.statusOpen;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LinkVoucherModal — pick from available vouchers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface LinkVoucherModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
dealId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
linkedVoucherIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkVoucherModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
dealId,
|
||||||
|
companyId,
|
||||||
|
contactId,
|
||||||
|
linkedVoucherIds,
|
||||||
|
}: LinkVoucherModalProps) {
|
||||||
|
const linkMutation = useLinkVoucherToDeal();
|
||||||
|
|
||||||
|
// Fetch available vouchers from company or contact
|
||||||
|
const companyVouchers = useCompanyVouchers(
|
||||||
|
companyId ?? '',
|
||||||
|
companyId && isOpen ? { pageSize: 100 } : undefined,
|
||||||
|
);
|
||||||
|
const contactVouchers = useContactVouchers(
|
||||||
|
contactId ?? '',
|
||||||
|
contactId && !companyId && isOpen ? { pageSize: 100 } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceData = companyId ? companyVouchers : contactVouchers;
|
||||||
|
const allVouchers: LexwareVoucher[] = sourceData.data?.data ?? [];
|
||||||
|
// Filter out already linked vouchers
|
||||||
|
const available = allVouchers.filter((v) => !linkedVoucherIds.has(v.id));
|
||||||
|
|
||||||
|
const handleLink = (voucherId: string) => {
|
||||||
|
linkMutation.mutate(
|
||||||
|
{ dealId, voucherId },
|
||||||
|
{ onSuccess: () => onClose() },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Beleg verknüpfen"
|
||||||
|
maxWidth="560px"
|
||||||
|
>
|
||||||
|
{!companyId && !contactId && (
|
||||||
|
<p className={styles.emptyText}>
|
||||||
|
Dieser Vorgang hat kein verknüpftes Unternehmen oder Kontakt mit
|
||||||
|
Lexware-Verbindung. Bitte zuerst einen Lexware-Kontakt verknüpfen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(companyId || contactId) && sourceData.isLoading && (
|
||||||
|
<p className={styles.loadingText}>Belege werden geladen...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(companyId || contactId) && !sourceData.isLoading && available.length === 0 && (
|
||||||
|
<p className={styles.emptyText}>
|
||||||
|
Keine weiteren Belege zum Verknüpfen verfügbar
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className={styles.searchResults}>
|
||||||
|
{available.map((v) => (
|
||||||
|
<div key={v.id} className={styles.searchResultItem}>
|
||||||
|
<div className={styles.searchResultInfo}>
|
||||||
|
<div className={styles.searchResultName}>
|
||||||
|
<span
|
||||||
|
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{VOUCHER_TYPE_LABELS[v.voucherType]}
|
||||||
|
</span>
|
||||||
|
{v.voucherNumber ?? 'Ohne Nummer'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchResultMeta}>
|
||||||
|
{v.voucherDate ? formatDate(v.voucherDate) : '—'}
|
||||||
|
{v.totalGrossAmount && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{currencyFormatter.format(
|
||||||
|
parseFloat(v.totalGrossAmount),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{v.title && <> · {v.title}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.searchResultBtn}
|
||||||
|
disabled={linkMutation.isPending}
|
||||||
|
onClick={() => handleLink(v.id)}
|
||||||
|
>
|
||||||
|
{linkMutation.isPending ? '...' : 'Verknüpfen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DealVouchersSection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface DealVouchersSectionProps {
|
||||||
|
dealId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DealVouchersSection({
|
||||||
|
dealId,
|
||||||
|
companyId,
|
||||||
|
contactId,
|
||||||
|
}: DealVouchersSectionProps) {
|
||||||
|
const { isModuleEnabled } = useCrmSettings();
|
||||||
|
const [isLinkOpen, setLinkOpen] = useState(false);
|
||||||
|
|
||||||
|
// Hide entire section if Lexware module is disabled
|
||||||
|
if (!isModuleEnabled('lexware')) return null;
|
||||||
|
const { data, isLoading } = useDealVouchers(dealId);
|
||||||
|
const unlinkMutation = useUnlinkVoucherFromDeal();
|
||||||
|
|
||||||
|
const dealVouchers: DealVoucher[] = data?.data ?? [];
|
||||||
|
const linkedVoucherIds = new Set(dealVouchers.map((dv) => dv.voucher.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<h2 className={styles.cardTitle}>
|
||||||
|
<span className={styles.lexwareLogo}>LX</span>
|
||||||
|
Belege ({dealVouchers.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtnPrimary}
|
||||||
|
onClick={() => setLinkOpen(true)}
|
||||||
|
style={{ padding: '0.25rem 0.625rem', fontSize: '0.8125rem' }}
|
||||||
|
>
|
||||||
|
+ Beleg verknüpfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className={styles.loadingText}>Belege werden geladen...</p>
|
||||||
|
) : dealVouchers.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>
|
||||||
|
Keine Belege mit diesem Vorgang verknüpft
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className={styles.voucherTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Nummer</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th style={{ width: 36 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dealVouchers.map((dv) => {
|
||||||
|
const v = dv.voucher;
|
||||||
|
return (
|
||||||
|
<tr key={dv.id}>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
|
||||||
|
>
|
||||||
|
{VOUCHER_TYPE_LABELS[v.voucherType]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{v.lexwareDeepLink ? (
|
||||||
|
<a
|
||||||
|
href={v.lexwareDeepLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.voucherLink}
|
||||||
|
style={{ fontSize: '0.8125rem' }}
|
||||||
|
>
|
||||||
|
{v.voucherNumber ?? '—'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
v.voucherNumber ?? '—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{v.voucherDate ? formatDate(v.voucherDate) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={voucherStatusClass(v.voucherStatus)}>
|
||||||
|
{v.voucherStatus ?? '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{v.totalGrossAmount
|
||||||
|
? currencyFormatter.format(
|
||||||
|
parseFloat(v.totalGrossAmount),
|
||||||
|
)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
unlinkMutation.mutate({
|
||||||
|
dealId,
|
||||||
|
voucherId: v.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={unlinkMutation.isPending}
|
||||||
|
title="Verknüpfung aufheben"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: unlinkMutation.isPending
|
||||||
|
? 'wait'
|
||||||
|
: 'pointer',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
padding: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link Voucher Modal */}
|
||||||
|
<LinkVoucherModal
|
||||||
|
isOpen={isLinkOpen}
|
||||||
|
onClose={() => setLinkOpen(false)}
|
||||||
|
dealId={dealId}
|
||||||
|
companyId={companyId}
|
||||||
|
contactId={contactId}
|
||||||
|
linkedVoucherIds={linkedVoucherIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
packages/frontend/src/crm/lexware/LexwareSearchModal.tsx
Normal file
147
packages/frontend/src/crm/lexware/LexwareSearchModal.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { useLexwareContactSearch } from '../hooks';
|
||||||
|
import type { LexwareContact } from '../types';
|
||||||
|
import styles from './LexwareSection.module.css';
|
||||||
|
|
||||||
|
interface LexwareSearchModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLink: (lexwareContactId: string) => void;
|
||||||
|
isLinking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a display name from a LexwareContact.
|
||||||
|
*/
|
||||||
|
function lexwareDisplayName(c: LexwareContact): string {
|
||||||
|
const companyName = c.company?.name;
|
||||||
|
const personName = [c.person?.firstName, c.person?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
if (companyName && personName) return `${companyName} (${personName})`;
|
||||||
|
return companyName || personName || 'Unbenannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the primary email from a LexwareContact.
|
||||||
|
*/
|
||||||
|
function lexwareEmail(c: LexwareContact): string | null {
|
||||||
|
const emails = c.emailAddresses;
|
||||||
|
if (!emails) return null;
|
||||||
|
return (
|
||||||
|
emails.business?.[0] ??
|
||||||
|
emails.office?.[0] ??
|
||||||
|
emails.private?.[0] ??
|
||||||
|
emails.other?.[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the primary address as a single-line string.
|
||||||
|
*/
|
||||||
|
function lexwareAddress(c: LexwareContact): string | null {
|
||||||
|
const addr = c.addresses?.billing?.[0] ?? c.addresses?.shipping?.[0];
|
||||||
|
if (!addr) return null;
|
||||||
|
const parts = [addr.street, [addr.zip, addr.city].filter(Boolean).join(' ')];
|
||||||
|
return parts.filter(Boolean).join(', ') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LexwareSearchModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onLink,
|
||||||
|
isLinking,
|
||||||
|
}: LexwareSearchModalProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [debouncedTerm, setDebouncedTerm] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Reset search when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSearchTerm('');
|
||||||
|
setDebouncedTerm('');
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Debounce search input
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedTerm(searchTerm.trim());
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useLexwareContactSearch(
|
||||||
|
{ name: debouncedTerm },
|
||||||
|
isOpen && debouncedTerm.length >= 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: LexwareContact[] = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Lexware Kontakt suchen & verknüpfen"
|
||||||
|
maxWidth="560px"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={styles.searchInput}
|
||||||
|
placeholder="Name oder Firma eingeben..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.searchResults}>
|
||||||
|
{debouncedTerm.length < 2 && (
|
||||||
|
<p className={styles.loadingText}>
|
||||||
|
Mindestens 2 Zeichen eingeben...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debouncedTerm.length >= 2 && isLoading && (
|
||||||
|
<p className={styles.loadingText}>Suche in Lexware...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debouncedTerm.length >= 2 && !isLoading && results.length === 0 && (
|
||||||
|
<p className={styles.loadingText}>
|
||||||
|
Keine Ergebnisse gefunden
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.map((contact) => {
|
||||||
|
const email = lexwareEmail(contact);
|
||||||
|
const address = lexwareAddress(contact);
|
||||||
|
return (
|
||||||
|
<div key={contact.id} className={styles.searchResultItem}>
|
||||||
|
<div className={styles.searchResultInfo}>
|
||||||
|
<div className={styles.searchResultName}>
|
||||||
|
{lexwareDisplayName(contact)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchResultMeta}>
|
||||||
|
{email && <span>{email}</span>}
|
||||||
|
{email && address && <span> · </span>}
|
||||||
|
{address && <span>{address}</span>}
|
||||||
|
{!email && !address && <span>Keine Details</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.searchResultBtn}
|
||||||
|
disabled={isLinking}
|
||||||
|
onClick={() => onLink(contact.id)}
|
||||||
|
>
|
||||||
|
{isLinking ? '...' : 'Verknüpfen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
packages/frontend/src/crm/lexware/LexwareSection.module.css
Normal file
360
packages/frontend/src/crm/lexware/LexwareSection.module.css
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
/* ============================================================
|
||||||
|
Lexware Office – Shared Section Styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lexwareLogo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #0066cc;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusLinked {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusNotLinked {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .statusLinked {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync info */
|
||||||
|
.syncInfo {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons row */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtnDanger {
|
||||||
|
composes: actionBtn;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtnDanger:hover {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtnPrimary {
|
||||||
|
composes: actionBtn;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtnPrimary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voucher table */
|
||||||
|
.voucherTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTable th {
|
||||||
|
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTable th:last-child {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTable td {
|
||||||
|
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTable td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTable tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherTypeBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeQuotation {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeOrderConfirmation {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeInvoice {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCreditNote {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .typeQuotation {
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .typeOrderConfirmation {
|
||||||
|
background: #312e81;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .typeInvoice {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .typeCreditNote {
|
||||||
|
background: #78350f;
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherLink {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucherLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOpen {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusPaid {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOverdue {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.emptyText {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter row for vouchers */
|
||||||
|
.filterRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search modal */
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResults {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultItem:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultName {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultMeta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultBtn {
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResultBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ERP Tag highlight */
|
||||||
|
.erpTag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: #0066cc;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme='dark']) .erpTag {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
398
packages/frontend/src/crm/lexware/LexwareSection.tsx
Normal file
398
packages/frontend/src/crm/lexware/LexwareSection.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
useLinkLexwareCompany,
|
||||||
|
useLinkLexwareContact,
|
||||||
|
useUnlinkLexwareCompany,
|
||||||
|
useUnlinkLexwareContact,
|
||||||
|
usePushToLexware,
|
||||||
|
useSyncFromLexware,
|
||||||
|
useCompanyVouchers,
|
||||||
|
useContactVouchers,
|
||||||
|
useRefreshCompanyVouchers,
|
||||||
|
useRefreshContactVouchers,
|
||||||
|
} from '../hooks';
|
||||||
|
import { useCrmSettings } from '../settings/CrmSettingsContext';
|
||||||
|
import type { LexwareVoucher, VoucherType } from '../types';
|
||||||
|
import { VOUCHER_TYPE_LABELS } from '../types';
|
||||||
|
import { LexwareSearchModal } from './LexwareSearchModal';
|
||||||
|
import styles from './LexwareSection.module.css';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function voucherTypeClass(type: VoucherType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'QUOTATION':
|
||||||
|
return styles.typeQuotation;
|
||||||
|
case 'ORDER_CONFIRMATION':
|
||||||
|
return styles.typeOrderConfirmation;
|
||||||
|
case 'INVOICE':
|
||||||
|
return styles.typeInvoice;
|
||||||
|
case 'CREDIT_NOTE':
|
||||||
|
return styles.typeCreditNote;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function voucherStatusClass(status: string | null): string {
|
||||||
|
if (!status) return '';
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === 'paid' || lower === 'bezahlt') return styles.statusPaid;
|
||||||
|
if (lower === 'overdue' || lower === 'überfällig') return styles.statusOverdue;
|
||||||
|
if (lower === 'open' || lower === 'offen') return styles.statusOpen;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VouchersTable
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function VouchersTable({ vouchers }: { vouchers: LexwareVoucher[] }) {
|
||||||
|
if (vouchers.length === 0) {
|
||||||
|
return <p className={styles.emptyText}>Keine Belege vorhanden</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className={styles.voucherTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Nummer</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vouchers.map((v) => (
|
||||||
|
<tr key={v.id}>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
|
||||||
|
>
|
||||||
|
{VOUCHER_TYPE_LABELS[v.voucherType]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{v.lexwareDeepLink ? (
|
||||||
|
<a
|
||||||
|
href={v.lexwareDeepLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.voucherLink}
|
||||||
|
style={{ fontSize: '0.8125rem' }}
|
||||||
|
>
|
||||||
|
{v.voucherNumber ?? '—'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
v.voucherNumber ?? '—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{v.voucherDate ? formatDate(v.voucherDate) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={voucherStatusClass(v.voucherStatus)}>
|
||||||
|
{v.voucherStatus ?? '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{v.totalGrossAmount
|
||||||
|
? currencyFormatter.format(parseFloat(v.totalGrossAmount))
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LexwareSection — Main Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface LexwareSectionProps {
|
||||||
|
entityType: 'company' | 'contact';
|
||||||
|
entityId: string;
|
||||||
|
lexwareContactId: string | null;
|
||||||
|
lexwareSyncedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LexwareSection({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
lexwareContactId,
|
||||||
|
lexwareSyncedAt,
|
||||||
|
}: LexwareSectionProps) {
|
||||||
|
const { isModuleEnabled } = useCrmSettings();
|
||||||
|
const [isSearchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [voucherTypeFilter, setVoucherTypeFilter] = useState<
|
||||||
|
VoucherType | ''
|
||||||
|
>('');
|
||||||
|
|
||||||
|
// Hide entire section if Lexware module is disabled
|
||||||
|
if (!isModuleEnabled('lexware')) return null;
|
||||||
|
|
||||||
|
const isLinked = !!lexwareContactId;
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const linkCompany = useLinkLexwareCompany();
|
||||||
|
const linkContact = useLinkLexwareContact();
|
||||||
|
const unlinkCompany = useUnlinkLexwareCompany();
|
||||||
|
const unlinkContact = useUnlinkLexwareContact();
|
||||||
|
const pushToLexware = usePushToLexware();
|
||||||
|
const syncFromLexware = useSyncFromLexware();
|
||||||
|
const refreshCompanyVouchers = useRefreshCompanyVouchers();
|
||||||
|
const refreshContactVouchers = useRefreshContactVouchers();
|
||||||
|
|
||||||
|
// Vouchers (only fetch when linked)
|
||||||
|
const vouchersParams = voucherTypeFilter
|
||||||
|
? { voucherType: voucherTypeFilter as VoucherType, pageSize: 50 }
|
||||||
|
: { pageSize: 50 };
|
||||||
|
const companyVouchers = useCompanyVouchers(
|
||||||
|
entityType === 'company' ? entityId : '',
|
||||||
|
entityType === 'company' && isLinked ? vouchersParams : undefined,
|
||||||
|
);
|
||||||
|
const contactVouchers = useContactVouchers(
|
||||||
|
entityType === 'contact' ? entityId : '',
|
||||||
|
entityType === 'contact' && isLinked ? vouchersParams : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vouchersData =
|
||||||
|
entityType === 'company' ? companyVouchers : contactVouchers;
|
||||||
|
const vouchers: LexwareVoucher[] = vouchersData.data?.data ?? [];
|
||||||
|
const isLoadingVouchers = vouchersData.isLoading;
|
||||||
|
|
||||||
|
const isAnyMutating =
|
||||||
|
linkCompany.isPending ||
|
||||||
|
linkContact.isPending ||
|
||||||
|
unlinkCompany.isPending ||
|
||||||
|
unlinkContact.isPending ||
|
||||||
|
pushToLexware.isPending ||
|
||||||
|
syncFromLexware.isPending ||
|
||||||
|
refreshCompanyVouchers.isPending ||
|
||||||
|
refreshContactVouchers.isPending;
|
||||||
|
|
||||||
|
const handleLink = (lexwareId: string) => {
|
||||||
|
if (entityType === 'company') {
|
||||||
|
linkCompany.mutate(
|
||||||
|
{ lexwareContactId: lexwareId, companyId: entityId },
|
||||||
|
{ onSuccess: () => setSearchOpen(false) },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
linkContact.mutate(
|
||||||
|
{ lexwareContactId: lexwareId, contactId: entityId },
|
||||||
|
{ onSuccess: () => setSearchOpen(false) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = () => {
|
||||||
|
if (entityType === 'company') {
|
||||||
|
unlinkCompany.mutate(entityId);
|
||||||
|
} else {
|
||||||
|
unlinkContact.mutate(entityId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePush = () => {
|
||||||
|
pushToLexware.mutate({ entityType, entityId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = () => {
|
||||||
|
syncFromLexware.mutate({ entityType, entityId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (entityType === 'company') {
|
||||||
|
refreshCompanyVouchers.mutate(entityId);
|
||||||
|
} else {
|
||||||
|
refreshContactVouchers.mutate(entityId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<h2 className={styles.cardTitle}>
|
||||||
|
<span className={styles.lexwareLogo}>LX</span>
|
||||||
|
Lexware Office
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusBadge} ${
|
||||||
|
isLinked ? styles.statusLinked : styles.statusNotLinked
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isLinked ? '#059669' : 'var(--color-text-muted)',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isLinked ? 'Verknüpft' : 'Nicht verknüpft'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync info */}
|
||||||
|
{isLinked && lexwareSyncedAt && (
|
||||||
|
<p className={styles.syncInfo}>
|
||||||
|
Letzte Synchronisation: {formatDate(lexwareSyncedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{!isLinked ? (
|
||||||
|
<button
|
||||||
|
className={styles.actionBtnPrimary}
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
disabled={isAnyMutating}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="7" cy="7" r="5" />
|
||||||
|
<path d="M11 11l3.5 3.5" />
|
||||||
|
</svg>
|
||||||
|
Lexware Kontakt suchen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={isAnyMutating}
|
||||||
|
title="Daten aus Lexware aktualisieren"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M1 8a7 7 0 0112.9-3.8M15 8a7 7 0 01-12.9 3.8" />
|
||||||
|
<path d="M14 1v3.2h-3.2M2 15v-3.2h3.2" />
|
||||||
|
</svg>
|
||||||
|
{syncFromLexware.isPending ? 'Sync...' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={handlePush}
|
||||||
|
disabled={isAnyMutating}
|
||||||
|
title="Daten zu Lexware übertragen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 12V3M4 7l4-4 4 4" />
|
||||||
|
</svg>
|
||||||
|
{pushToLexware.isPending ? 'Push...' : 'Push'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isAnyMutating}
|
||||||
|
title="Belege aus Lexware neu laden"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M1 1v5h5M15 15v-5h-5" />
|
||||||
|
<path d="M13.5 6A6 6 0 003.3 3.3L1 6M2.5 10a6 6 0 0010.2 2.7L15 10" />
|
||||||
|
</svg>
|
||||||
|
Belege aktualisieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtnDanger}
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isAnyMutating}
|
||||||
|
title="Verknüpfung aufheben"
|
||||||
|
>
|
||||||
|
{(unlinkCompany.isPending || unlinkContact.isPending)
|
||||||
|
? 'Trennen...'
|
||||||
|
: 'Trennen'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vouchers */}
|
||||||
|
{isLinked && (
|
||||||
|
<>
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
value={voucherTypeFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVoucherTypeFilter(e.target.value as VoucherType | '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Alle Belegarten</option>
|
||||||
|
<option value="QUOTATION">Angebote</option>
|
||||||
|
<option value="ORDER_CONFIRMATION">Auftragsbestätigungen</option>
|
||||||
|
<option value="INVOICE">Rechnungen</option>
|
||||||
|
<option value="CREDIT_NOTE">Gutschriften</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingVouchers ? (
|
||||||
|
<p className={styles.loadingText}>Belege werden geladen...</p>
|
||||||
|
) : (
|
||||||
|
<VouchersTable vouchers={vouchers} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Modal */}
|
||||||
|
<LexwareSearchModal
|
||||||
|
isOpen={isSearchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onLink={handleLink}
|
||||||
|
isLinking={linkCompany.isPending || linkContact.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import { Navigate } from 'react-router-dom';
|
||||||
// Types
|
// Types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines';
|
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines' | 'lexware';
|
||||||
|
|
||||||
export interface CrmModuleConfig {
|
export interface CrmModuleConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -41,6 +41,7 @@ const DEFAULT_SETTINGS: CrmSettings = {
|
||||||
companies: { enabled: true },
|
companies: { enabled: true },
|
||||||
deals: { enabled: true },
|
deals: { enabled: true },
|
||||||
pipelines: { enabled: true },
|
pipelines: { enabled: true },
|
||||||
|
lexware: { enabled: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,18 @@ const MODULES: ModuleDef[] = [
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'lexware',
|
||||||
|
name: 'Lexware Office',
|
||||||
|
description: 'Lexware-Kontaktverknüpfung & Belegansicht auf Detail-Seiten',
|
||||||
|
icon: (
|
||||||
|
<svg {...iconProps}>
|
||||||
|
<rect x="1" y="3" width="14" height="10" rx="1" />
|
||||||
|
<path d="M4 7h8M4 10h5" />
|
||||||
|
<circle cx="12" cy="10" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export interface Contact {
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
lexwareContactId: string | null;
|
||||||
|
lexwareContactVersion: number | null;
|
||||||
|
lexwareSyncedAt: string | null;
|
||||||
activities?: Activity[];
|
activities?: Activity[];
|
||||||
company?: {
|
company?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -180,6 +183,7 @@ export interface Deal {
|
||||||
companyName: string | null;
|
companyName: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
company?: { id: string; name: string } | null;
|
company?: { id: string; name: string } | null;
|
||||||
|
dealVouchers?: DealVoucher[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateDealPayload {
|
export interface CreateDealPayload {
|
||||||
|
|
@ -219,7 +223,10 @@ export interface Company {
|
||||||
updatedBy: string | null;
|
updatedBy: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
_count?: { contacts: number; deals: number };
|
lexwareContactId: string | null;
|
||||||
|
lexwareContactVersion: number | null;
|
||||||
|
lexwareSyncedAt: string | null;
|
||||||
|
_count?: { contacts: number; deals: number; lexwareVouchers?: number };
|
||||||
contacts?: {
|
contacts?: {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
|
|
@ -313,3 +320,69 @@ export interface CompaniesQueryParams {
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lexware Office Integration ---
|
||||||
|
|
||||||
|
export type VoucherType =
|
||||||
|
| 'QUOTATION'
|
||||||
|
| 'ORDER_CONFIRMATION'
|
||||||
|
| 'INVOICE'
|
||||||
|
| 'CREDIT_NOTE';
|
||||||
|
|
||||||
|
export const VOUCHER_TYPE_LABELS: Record<VoucherType, string> = {
|
||||||
|
QUOTATION: 'Angebot',
|
||||||
|
ORDER_CONFIRMATION: 'Auftragsbestätigung',
|
||||||
|
INVOICE: 'Rechnung',
|
||||||
|
CREDIT_NOTE: 'Gutschrift',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LexwareVoucher {
|
||||||
|
id: string;
|
||||||
|
voucherType: VoucherType;
|
||||||
|
voucherNumber: string | null;
|
||||||
|
voucherDate: string | null;
|
||||||
|
voucherStatus: string | null;
|
||||||
|
totalGrossAmount: string | null;
|
||||||
|
totalNetAmount: string | null;
|
||||||
|
totalTaxAmount: string | null;
|
||||||
|
currency: string;
|
||||||
|
title: string | null;
|
||||||
|
lineItemsCount: number | null;
|
||||||
|
lineItemsJson: string | null;
|
||||||
|
lexwareDeepLink: string | null;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealVoucher {
|
||||||
|
id: string;
|
||||||
|
linkedAt: string;
|
||||||
|
voucher: LexwareVoucher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LexwareContact {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
roles: { customer?: Record<string, unknown>; vendor?: Record<string, unknown> };
|
||||||
|
company?: { name?: string; taxId?: string; allowTaxFreeInvoices?: boolean };
|
||||||
|
person?: { firstName?: string; lastName?: string; salutation?: string };
|
||||||
|
addresses?: {
|
||||||
|
billing?: { street?: string; zip?: string; city?: string; countryCode?: string }[];
|
||||||
|
shipping?: { street?: string; zip?: string; city?: string; countryCode?: string }[];
|
||||||
|
};
|
||||||
|
emailAddresses?: { business?: string[]; office?: string[]; private?: string[]; other?: string[] };
|
||||||
|
phoneNumbers?: { business?: string[]; office?: string[]; mobile?: string[]; private?: string[]; fax?: string[] };
|
||||||
|
note?: string;
|
||||||
|
archived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LexwareContactSearchParams {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LexwareVouchersQueryParams {
|
||||||
|
voucherType?: VoucherType;
|
||||||
|
voucherStatus?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue