mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +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,
|
||||
UpdateCompanyPayload,
|
||||
CompaniesQueryParams,
|
||||
LexwareContact,
|
||||
LexwareContactSearchParams,
|
||||
LexwareVoucher,
|
||||
LexwareVouchersQueryParams,
|
||||
DealVoucher,
|
||||
PaginatedResponse,
|
||||
SingleResponse,
|
||||
} from './types';
|
||||
|
|
@ -199,3 +204,124 @@ export const companiesApi = {
|
|||
.delete<SingleResponse<Company>>(`/crm/companies/${id}`)
|
||||
.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 { CompanyFormModal } from './CompanyFormModal';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { LexwareSection } from '../lexware/LexwareSection';
|
||||
import type { DealStatus } from '../types';
|
||||
import styles from './CompanyDetailPage.module.css';
|
||||
|
||||
|
|
@ -226,7 +227,7 @@ export function CompanyDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechts: Kontakte + Vorgänge */}
|
||||
{/* Rechts: Kontakte + Vorgänge + Lexware */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* Kontakte */}
|
||||
<div className={styles.card}>
|
||||
|
|
@ -456,6 +457,14 @@ export function CompanyDetailPage() {
|
|||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lexware Office */}
|
||||
<LexwareSection
|
||||
entityType="company"
|
||||
entityId={company.id}
|
||||
lexwareContactId={company.lexwareContactId ?? null}
|
||||
lexwareSyncedAt={company.lexwareSyncedAt ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useContact, useDeals, useDeleteContact } from '../hooks';
|
|||
import { ContactFormModal } from './ContactFormModal';
|
||||
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { LexwareSection } from '../lexware/LexwareSection';
|
||||
import type { Contact, Activity, ActivityType, ContactType } from '../types';
|
||||
import styles from './ContactDetailPage.module.css';
|
||||
|
||||
|
|
@ -440,6 +441,16 @@ export function ContactDetailPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lexware Office */}
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<LexwareSection
|
||||
entityType="contact"
|
||||
entityId={contact.id}
|
||||
lexwareContactId={contact.lexwareContactId ?? null}
|
||||
lexwareSyncedAt={contact.lexwareSyncedAt ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechts: Aktivitäten-Timeline */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
|||
import { useDeal, useDeleteDeal } from '../hooks';
|
||||
import { DealFormModal } from './DealFormModal';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { DealVouchersSection } from '../lexware/DealVouchersSection';
|
||||
import type { DealStatus } from '../types';
|
||||
import styles from './DealDetailPage.module.css';
|
||||
|
||||
|
|
@ -247,6 +248,13 @@ export function DealDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Belege (Lexware Vouchers) */}
|
||||
<DealVouchersSection
|
||||
dealId={deal.id}
|
||||
companyId={deal.companyId ?? deal.company?.id ?? null}
|
||||
contactId={deal.contactId ?? deal.contact?.id ?? null}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<DealFormModal
|
||||
isOpen={isEditOpen}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
// ============================================================
|
||||
|
||||
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 {
|
||||
ContactsQueryParams,
|
||||
DealsQueryParams,
|
||||
|
|
@ -21,6 +29,8 @@ import type {
|
|||
CompaniesQueryParams,
|
||||
CreateCompanyPayload,
|
||||
UpdateCompanyPayload,
|
||||
LexwareContactSearchParams,
|
||||
LexwareVouchersQueryParams,
|
||||
} from './types';
|
||||
|
||||
// --- Query Key Factory ---
|
||||
|
|
@ -55,6 +65,17 @@ export const crmKeys = {
|
|||
['crm', 'companies', 'list', params] 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
|
||||
// ============================================================
|
||||
|
||||
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines';
|
||||
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines' | 'lexware';
|
||||
|
||||
export interface CrmModuleConfig {
|
||||
enabled: boolean;
|
||||
|
|
@ -41,6 +41,7 @@ const DEFAULT_SETTINGS: CrmSettings = {
|
|||
companies: { enabled: true },
|
||||
deals: { enabled: true },
|
||||
pipelines: { enabled: true },
|
||||
lexware: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,18 @@ const MODULES: ModuleDef[] = [
|
|||
</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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lexwareContactId: string | null;
|
||||
lexwareContactVersion: number | null;
|
||||
lexwareSyncedAt: string | null;
|
||||
activities?: Activity[];
|
||||
company?: {
|
||||
id: string;
|
||||
|
|
@ -180,6 +183,7 @@ export interface Deal {
|
|||
companyName: string | null;
|
||||
} | null;
|
||||
company?: { id: string; name: string } | null;
|
||||
dealVouchers?: DealVoucher[];
|
||||
}
|
||||
|
||||
export interface CreateDealPayload {
|
||||
|
|
@ -219,7 +223,10 @@ export interface Company {
|
|||
updatedBy: string | null;
|
||||
createdAt: 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?: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
|
|
@ -313,3 +320,69 @@ export interface CompaniesQueryParams {
|
|||
sort?: string;
|
||||
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