feature+refactor/fewThingsIGuess #22

Merged
Seazn merged 2 commits from feature+refactor/fewThingsIGuess into dev 2026-05-21 17:34:23 +00:00
10 changed files with 507 additions and 263 deletions
Showing only changes of commit bcc953edc1 - Show all commits

View File

@ -1,71 +1,68 @@
'use client' 'use client'
import { useTranslation } from '../../../i18n/useTranslation'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../../i18n/useTranslation';
import { useState, useEffect } from 'react'
import useContractManagement from '../hooks/useContractManagement' import useContractManagement from '../hooks/useContractManagement'
function fileToDataUrl(file: File): Promise<string> { const LOGO_ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
return new Promise((resolve, reject) => { const MAX_LOGO_BYTES = 1024 * 1024
const reader = new FileReader()
reader.onerror = () => reject(new Error('Failed to read file')) type CompanySettingsForm = {
reader.onload = () => { company_name: string
const result = reader.result company_street: string
if (typeof result === 'string') resolve(result) company_postal_city: string
else reject(new Error('Invalid file result')) company_country: string
company_logo_base64: string | null
company_logo_mime_type: string | null
} }
function fileToBase64Payload(file: File) {
return new Promise<{ base64: string; mimeType: string }>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : ''
const match = result.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/)
if (!match) {
reject(new Error('invalid_data_url'))
return
}
resolve({ mimeType: match[1], base64: match[2] })
}
reader.onerror = () => reject(new Error('read_failed'))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
} }
function summarizeForLog(payload: Record<string, any>) {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(payload)) {
if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) {
out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) }
} else if (typeof v === 'string' && v.length > 200) {
out[k] = { kind: 'string', len: v.length, head: v.slice(0, 32) }
} else {
out[k] = v
}
}
return out
}
export default function CompanySettingsPanel() { export default function CompanySettingsPanel() {
const { t } = useTranslation(); const { t } = useTranslation()
const { getCompanySettings, updateCompanySettings } = useContractManagement() const { getCompanySettings, updateCompanySettings } = useContractManagement()
const logoInputRef = useRef<HTMLInputElement | null>(null)
const [form, setForm] = useState({ const [form, setForm] = useState<CompanySettingsForm>({
company_name: '', company_name: '',
company_street: '', company_street: '',
company_postal_city: '', company_postal_city: '',
company_country: '', company_country: '',
company_logo_base64: null,
company_logo_mime_type: null,
}) })
const [hasQr60, setHasQr60] = useState(false)
const [hasQr120, setHasQr120] = useState(false)
const [qr60DataUrl, setQr60DataUrl] = useState<string>('')
const [qr120DataUrl, setQr120DataUrl] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [saveError, setSaveError] = useState<string>('') const [saveError, setSaveError] = useState<string>('')
const [logoError, setLogoError] = useState<string>('')
useEffect(() => { useEffect(() => {
getCompanySettings() getCompanySettings()
.then(data => { .then((data) => {
setForm({ setForm({
company_name: data.company_name || '', company_name: data.company_name || '',
company_street: data.company_street || '', company_street: data.company_street || '',
company_postal_city: data.company_postal_city || '', company_postal_city: data.company_postal_city || '',
company_country: data.company_country || '', company_country: data.company_country || '',
company_logo_base64: data.company_logo_base64 || null,
company_logo_mime_type: data.company_logo_mime_type || null,
}) })
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64
setHasQr60(!!qr60)
setHasQr120(!!qr120)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@ -73,194 +70,203 @@ export default function CompanySettingsPanel() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target const { name, value } = e.target
setForm(prev => ({ ...prev, [name]: value })) setForm((prev) => ({ ...prev, [name]: value }))
setSaved(false) setSaved(false)
} }
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
setLogoError('')
if (!file) return
if (!LOGO_ACCEPTED_TYPES.includes(file.type)) {
setLogoError(t('autofix.k2bd38d5e'))
if (logoInputRef.current) logoInputRef.current.value = ''
return
}
if (file.size > MAX_LOGO_BYTES) {
setLogoError(t('autofix.k394b7f42'))
if (logoInputRef.current) logoInputRef.current.value = ''
return
}
try {
const { base64, mimeType } = await fileToBase64Payload(file)
setForm((prev) => ({
...prev,
company_logo_base64: base64,
company_logo_mime_type: mimeType,
}))
setSaved(false)
} catch {
setLogoError(t('autofix.k8a1d4c20'))
} finally {
if (logoInputRef.current) logoInputRef.current.value = ''
}
}
const handleRemoveLogo = () => {
setForm((prev) => ({
...prev,
company_logo_base64: null,
company_logo_mime_type: null,
}))
setLogoError('')
setSaved(false)
if (logoInputRef.current) logoInputRef.current.value = ''
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
setSaved(false) setSaved(false)
setSaveError('') setSaveError('')
try { try {
// IMPORTANT: send `payload` (full strings), not the redacted log view. await updateCompanySettings(form)
const payload: any = { ...form }
if (qr60DataUrl) payload.qr_code_60_base64 = qr60DataUrl
if (qr120DataUrl) payload.qr_code_120_base64 = qr120DataUrl
// For logging only (redacted); never send this object.
const logPayload: any = summarizeForLog(payload)
try {
const qr60 = payload.qr_code_60_base64
const qr120 = payload.qr_code_120_base64
console.info('[CompanySettingsPanel] updateCompanySettings payload', {
logPayload,
keys: Object.keys(payload),
jsonLength: JSON.stringify(payload).length,
qrFieldTypes: {
qr_code_60_base64: qr60 ? typeof qr60 : null,
qr_code_120_base64: qr120 ? typeof qr120 : null,
},
qrFieldLengths: {
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
},
})
if (qr60 && typeof qr60 !== 'string') console.warn('[CompanySettingsPanel] qr_code_60_base64 is not a string!', qr60)
if (qr120 && typeof qr120 !== 'string') console.warn('[CompanySettingsPanel] qr_code_120_base64 is not a string!', qr120)
} catch {}
await updateCompanySettings(payload)
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 3000) setTimeout(() => setSaved(false), 3000)
} catch { } catch {
setSaveError('Could not save settings.') setSaveError(t('autofix.k95a16b2b'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleQrUpload = async (which: '60' | '120', file: File | null) => { const logoPreviewSrc = form.company_logo_base64
setSaved(false) ? `data:${form.company_logo_mime_type || 'image/png'};base64,${form.company_logo_base64}`
setSaveError('') : null
if (!file) return
// Backend accepts 10MB JSON, but base64 expands the payload.
// Keep a conservative limit to avoid 413 Payload Too Large.
const MAX_FILE_BYTES = 7_000_000
if (file.size > MAX_FILE_BYTES) {
setSaveError('QR image is too large. Please upload a smaller PNG.')
return
}
if (file.type && file.type !== 'image/png') {
setSaveError('Please upload a PNG file for the QR code.')
return
}
try {
const dataUrl = await fileToDataUrl(file)
// Normalize to raw base64, to match other endpoints (e.g. company stamp upload)
const m = dataUrl.match(/^data:(.+?);base64,(.*)$/)
const base64 = m ? m[2] : dataUrl
if (which === '60') {
setQr60DataUrl(base64)
setHasQr60(true)
} else {
setQr120DataUrl(base64)
setHasQr120(true)
}
} catch {
setSaveError('Could not read QR image file.')
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center gap-2 text-sm text-gray-500 py-4"> <div className="flex items-center gap-2 py-4 text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />{t('autofix.k81a1b900')}</div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-900" />
{t('autofix.k81a1b900')}
</div>
) )
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.k0198ce13')}</h3>
<p className="text-sm text-slate-500">{t('autofix.k03d7361d')}</p>
<p className="text-xs text-slate-400">{t('autofix.k1c2b0975')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={logoInputRef}
type="file"
accept={LOGO_ACCEPTED_TYPES.join(',')}
onChange={handleLogoChange}
className="hidden"
/>
<button
type="button"
onClick={() => logoInputRef.current?.click()}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-100"
>
{logoPreviewSrc ? t('autofix.k7d3f0e11') : t('autofix.k089f42a1')}
</button>
{logoPreviewSrc && (
<button
type="button"
onClick={handleRemoveLogo}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-semibold text-red-700 transition hover:bg-red-100"
>
{t('autofix.k0d8e2d01')}
</button>
)}
</div>
</div>
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 bg-white p-4">
{logoPreviewSrc ? (
<img
src={logoPreviewSrc}
alt={t('autofix.k0198ce13')}
className="max-h-24 max-w-full object-contain"
/>
) : (
<div className="text-sm text-slate-500">{t('autofix.k432b8a12')}</div>
)}
</div>
{logoError && <div className="mt-3 text-sm font-medium text-red-600">{logoError}</div>}
</div>
<div> <div>
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k33918465')}</label> <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
{t('autofix.kaa8bbc8e')}
</h3>
<p className="mb-4 text-sm text-slate-500">{t('autofix.k15bea9bb')}</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="company_name" className="mb-1 block text-sm font-medium text-gray-700">{t('autofix.k33918465')}</label>
<input <input
type="text" type="text"
id="company_name" id="company_name"
name="company_name" name="company_name"
value={form.company_name} value={form.company_name}
onChange={handleChange} onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
placeholder={t('autofix.k91e69df1')} placeholder={t('autofix.k91e69df1')}
/> />
</div> </div>
<div> <div>
<label htmlFor="company_street" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="company_street" className="mb-1 block text-sm font-medium text-gray-700">Street</label>
Street
</label>
<input <input
type="text" type="text"
id="company_street" id="company_street"
name="company_street" name="company_street"
value={form.company_street} value={form.company_street}
onChange={handleChange} onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
placeholder={t('autofix.k81c7c2f2')} placeholder={t('autofix.k81c7c2f2')}
/> />
</div> </div>
<div> <div>
<label htmlFor="company_postal_city" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="company_postal_city" className="mb-1 block text-sm font-medium text-gray-700">Postal Code &amp; City</label>
Postal Code &amp; City
</label>
<input <input
type="text" type="text"
id="company_postal_city" id="company_postal_city"
name="company_postal_city" name="company_postal_city"
value={form.company_postal_city} value={form.company_postal_city}
onChange={handleChange} onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
placeholder={t('autofix.k93165aea')} placeholder={t('autofix.k93165aea')}
/> />
</div> </div>
<div> <div>
<label htmlFor="company_country" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="company_country" className="mb-1 block text-sm font-medium text-gray-700">Country</label>
Country
</label>
<input <input
type="text" type="text"
id="company_country" id="company_country"
name="company_country" name="company_country"
value={form.company_country} value={form.company_country}
onChange={handleChange} onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
placeholder="Germany" placeholder="Germany"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (60 pcs)</label>
<input
type="file"
accept="image/png"
onChange={e => handleQrUpload('60', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/>
<div className="mt-1 text-xs text-gray-500">{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}</div>
</div> </div>
<div> {saveError && <div className="text-sm font-medium text-red-600">{saveError}</div>}
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (120 pcs)</label>
<input
type="file"
accept="image/png"
onChange={e => handleQrUpload('120', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/>
<div className="mt-1 text-xs text-gray-500">{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}</div>
</div>
</div>
{saveError && (
<div className="text-sm text-red-600 font-medium">{saveError}</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${ className={`rounded-lg px-5 py-2 text-sm font-semibold text-white transition-colors ${saving ? 'cursor-not-allowed bg-gray-400' : 'bg-blue-900 hover:bg-blue-800'}`}
saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800' >
}`} {saving ? t('autofix.kac6cedc7') : 'Save'}
>{saving ? t('autofix.kac6cedc7') : 'Save'}</button> </button>
{saved && ( {saved && <span className="text-sm font-medium text-green-600">{t('autofix.ka29ac729')}</span>}
<span className="text-sm text-green-600 font-medium">{t('autofix.ka29ac729')}</span>
)}
</div> </div>
</form> </form>
) )

View File

@ -1,6 +1,8 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement'; import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'; import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
@ -75,6 +77,12 @@ type ContractTypeSection = {
activeTemplates: number; activeTemplates: number;
}; };
type VersionHistoryModalState = {
trackTitle: string;
languageLabel: string;
templates: ContractTemplate[];
};
type NormalizedTemplate = DocumentTemplate & { type NormalizedTemplate = DocumentTemplate & {
_id?: string; _id?: string;
uuid?: string; uuid?: string;
@ -346,6 +354,143 @@ function StatusBadge({ status }: { status: string }) {
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium leading-none ${cls}`}>{labels[status] || status}</span>; return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium leading-none ${cls}`}>{labels[status] || status}</span>;
} }
function TemplateVersionHistoryModal({
history,
open,
onClose,
onEdit,
onPreview,
onGenPdf,
onDownloadPdf,
onToggleState,
}: {
history: VersionHistoryModalState | null;
open: boolean;
onClose: () => void;
onEdit?: (id: string) => void;
onPreview: (id: string) => void;
onGenPdf: (id: string) => void;
onDownloadPdf: (id: string) => void;
onToggleState: (id: string, current: string) => Promise<void>;
}) {
const templates = history?.templates || [];
return (
<Transition show={open} as={React.Fragment}>
<Dialog onClose={onClose} className="relative z-1150">
<Transition.Child
as={React.Fragment}
enter="transition-opacity ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 md:p-6">
<Transition.Child
as={React.Fragment}
enter="transition-all ease-out duration-200"
enterFrom="opacity-0 translate-y-2 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="transition-all ease-in duration-150"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-2 sm:scale-95"
>
<Dialog.Panel className="w-full max-w-4xl rounded-[28px] border border-white/80 bg-white p-5 shadow-[0_30px_90px_-40px_rgba(15,23,42,0.45)] md:p-6">
<div className="flex items-start justify-between gap-4 border-b border-slate-200 pb-4">
<div>
<Dialog.Title className="text-xl font-semibold tracking-tight text-slate-950">Version history</Dialog.Title>
<p className="mt-1 text-sm text-slate-600">
{history ? `${history.trackTitle}${history.languageLabel}` : ''}
</p>
<p className="mt-2 text-sm text-slate-500">
Hidden revisions stay out of the main list, but remain available for preview, editing and re-activation here.
</p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition hover:border-slate-300 hover:text-slate-700"
aria-label="Close version history"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-5 max-h-[70vh] space-y-3 overflow-y-auto pr-1">
{templates.map((template) => (
<div key={template.id} className="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-slate-900">v{template.version}</div>
<StatusBadge status={template.status} />
</div>
<p className="mt-2 truncate text-sm font-medium text-slate-900">{template.name}</p>
<div className="mt-1 text-xs text-slate-500">{formatTimestamp(template.updatedAt) || 'n/a'}</div>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
{onEdit && (
<button
onClick={() => onEdit(template.id)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
Edit
</button>
)}
<button
onClick={() => onPreview(template.id)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
Preview
</button>
<button
onClick={() => onGenPdf(template.id)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
PDF
</button>
<button
onClick={() => onDownloadPdf(template.id)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
Download
</button>
<button
onClick={() => onToggleState(template.id, template.status)}
className={`rounded-xl px-3 py-2 text-xs font-semibold transition ${template.status === 'published'
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border border-slate-900 bg-slate-900 text-white hover:bg-slate-800'}`}
>
{template.status === 'published' ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
</div>
))}
{!templates.length && (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-10 text-center text-sm text-slate-500">
No hidden versions for this language.
</div>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) { export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [items, setItems] = useState<ContractTemplate[]>([]); const [items, setItems] = useState<ContractTemplate[]>([]);
@ -354,6 +499,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const [selectedFamily, setSelectedFamily] = useState<TemplateFamilyKey>('contract'); const [selectedFamily, setSelectedFamily] = useState<TemplateFamilyKey>('contract');
const [selectedLanguageByTrack, setSelectedLanguageByTrack] = useState<Record<string, string>>({}); const [selectedLanguageByTrack, setSelectedLanguageByTrack] = useState<Record<string, string>>({});
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null); const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null);
const [versionHistory, setVersionHistory] = useState<VersionHistoryModalState | null>(null);
const { const {
listTemplates, listTemplates,
@ -533,7 +679,12 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const confirmToggleState = async () => { const confirmToggleState = async () => {
if (!pendingToggle) return; if (!pendingToggle) return;
const shouldCloseVersionHistory = pendingToggle.target === 'active'
&& Boolean(versionHistory?.templates.some((template) => template.id === pendingToggle.id));
await executeToggleState(pendingToggle.id, pendingToggle.target); await executeToggleState(pendingToggle.id, pendingToggle.target);
if (shouldCloseVersionHistory) {
setVersionHistory(null);
}
setPendingToggle(null); setPendingToggle(null);
}; };
@ -561,6 +712,13 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
if (!visibleLanguageColumn) return null; if (!visibleLanguageColumn) return null;
const primaryTemplate = visibleLanguageColumn.activeTemplate || visibleLanguageColumn.templates[0] || null;
const visibleTemplates = primaryTemplate ? [primaryTemplate] : [];
const hiddenTemplates = primaryTemplate
? visibleLanguageColumn.templates.filter((template) => template.id !== primaryTemplate.id)
: visibleLanguageColumn.templates;
const hiddenVersionCount = hiddenTemplates.length;
return ( return (
<article <article
key={track.key} key={track.key}
@ -635,17 +793,34 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
<div className="mt-1 text-sm text-slate-500"> <div className="mt-1 text-sm text-slate-500">
{visibleLanguageColumn.activeTemplate {visibleLanguageColumn.activeTemplate
? `Active version: v${visibleLanguageColumn.activeTemplate.version}` ? `Active version: v${visibleLanguageColumn.activeTemplate.version}`
: 'No active version yet'} : primaryTemplate
? `Latest version: v${primaryTemplate.version}`
: 'No version yet'}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600"> <div className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
{visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'} {visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'}
</div> </div>
{hiddenVersionCount > 0 && (
<button
type="button"
onClick={() => setVersionHistory({
trackTitle: track.title,
languageLabel: visibleLanguageColumn.label,
templates: hiddenTemplates,
})}
className="rounded-full border border-slate-200 bg-slate-900 px-3 py-1 text-xs font-semibold text-white transition hover:bg-slate-800"
>
Version history ({hiddenVersionCount})
</button>
)}
</div>
</div> </div>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{visibleLanguageColumn.templates.map((template) => ( {visibleTemplates.map((template) => (
<div key={template.id} className="flex flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between"> <div key={template.id} className="flex flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -851,6 +1026,17 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
onClose={() => setPendingToggle(null)} onClose={() => setPendingToggle(null)}
onConfirm={confirmToggleState} onConfirm={confirmToggleState}
/> />
<TemplateVersionHistoryModal
history={versionHistory}
open={Boolean(versionHistory)}
onClose={() => setVersionHistory(null)}
onEdit={onEdit}
onPreview={onPreview}
onGenPdf={onGenPdf}
onDownloadPdf={onDownloadPdf}
onToggleState={onToggleState}
/>
</div> </div>
); );
} }

View File

@ -545,12 +545,8 @@ export default function useContractManagement() {
company_street?: string company_street?: string
company_postal_city?: string company_postal_city?: string
company_country?: string company_country?: string
// NEW: QR codes for invoices (base64 or data URL) company_logo_base64?: string | null
qr_code_60_base64?: string | null company_logo_mime_type?: string | null
qr_code_120_base64?: string | null
// NEW: allow camelCase too (backend supports both)
qrCode60Base64?: string | null
qrCode120Base64?: string | null
} }
const getCompanySettings = useCallback(async () => { const getCompanySettings = useCallback(async () => {
@ -558,35 +554,6 @@ export default function useContractManagement() {
}, [authorizedFetch]); }, [authorizedFetch]);
const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => { const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => {
// Debug request body in browser console (redacts base64 values)
try {
// IMPORTANT: `data` is the real payload object; `redacted` is for logs only.
const json = JSON.stringify(data);
const redacted = redactJsonForLogs(data);
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64;
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64;
console.info('[CM][company-settings] PUT body', {
redacted,
jsonLength: json.length,
keys: Object.keys(data || {}),
qrFieldTypes: {
qr_code_60_base64: qr60 ? typeof qr60 : null,
qr_code_120_base64: qr120 ? typeof qr120 : null,
},
qrFieldLengths: {
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
},
});
if (qr60 && typeof qr60 !== 'string') {
console.warn('[CM][company-settings] qr_code_60_base64 is not a string!', qr60);
}
if (qr120 && typeof qr120 !== 'string') {
console.warn('[CM][company-settings] qr_code_120_base64 is not a string!', qr120);
}
} catch {}
return authorizedFetch<CompanySettings>('/api/admin/company-settings', { return authorizedFetch<CompanySettings>('/api/admin/company-settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),

View File

@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation';
import { useTranslation } from '../../i18n/useTranslation'; import { useTranslation } from '../../i18n/useTranslation';
const NAV = [ const NAV = [
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> }, { key: 'stamp', label: 'Company Details', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
{ key: 'mailTemplates', label: 'Mail Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> }, { key: 'mailTemplates', label: 'Mail Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> }, { key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> }, { key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
@ -97,11 +97,6 @@ export default function ContractManagementPage() {
{t('autofix.k39791457')} {t('autofix.k39791457')}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">{t('autofix.k61d66984')}</span>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">{t('autofix.k74823841')}</span>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">{t('autofix.kccff045c')}</span>
</div>
</div> </div>
</header> </header>
@ -111,15 +106,14 @@ export default function ContractManagementPage() {
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
{t('autofix.ka5f38d19')} {t('autofix.ka5f38d19')}
</h2> </h2>
<ContractUploadCompanyStamp onUploaded={bumpRefresh} /> <CompanySettingsPanel />
<div className="mt-8 border-t border-slate-200 pt-6"> <div className="mt-8 border-t border-slate-200 pt-6">
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900"> <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
{t('autofix.kaa8bbc8e')} {t('autofix.k54d7cbef')}
</h3> </h3>
<p className="mb-4 text-sm text-slate-500">{t('autofix.k15bea9bb')}</p> <ContractUploadCompanyStamp onUploaded={bumpRefresh} />
<CompanySettingsPanel />
</div> </div>
</section> </section>
)} )}

View File

@ -27,6 +27,12 @@ export type AdminInvoice = {
updated_at?: string | null; updated_at?: string | null;
}; };
export type AdminInvoiceRevenueSummary = {
totalPaidAllTime: number;
currency?: string | null;
paidInvoiceCount?: number;
};
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) { export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
const accessToken = useAuthStore(s => s.accessToken); const accessToken = useAuthStore(s => s.accessToken);
const [invoices, setInvoices] = useState<AdminInvoice[]>([]); const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
@ -91,3 +97,57 @@ export function useAdminInvoices(params?: { status?: string; limit?: number; off
return { invoices, loading, error, reload: fetchInvoices }; return { invoices, loading, error, reload: fetchInvoices };
} }
export function useAdminInvoiceRevenueSummary() {
const accessToken = useAuthStore(s => s.accessToken);
const [summary, setSummary] = useState<AdminInvoiceRevenueSummary | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const inFlight = useRef<AbortController | null>(null);
const fetchSummary = useCallback(async () => {
setError('');
inFlight.current?.abort();
const controller = new AbortController();
inFlight.current = controller;
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const url = `${base}/api/admin/invoices/revenue-summary`;
setLoading(true);
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
signal: controller.signal,
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
setSummary(null);
setError(body?.message || `Failed to load revenue summary (${res.status})`);
return;
}
setSummary(body?.data || { totalPaidAllTime: 0, currency: 'EUR', paidInvoiceCount: 0 });
} catch (e: any) {
if (e?.name === 'AbortError') return;
setError(e?.message || 'Network error');
setSummary(null);
} finally {
setLoading(false);
if (inFlight.current === controller) inFlight.current = null;
}
}, [accessToken]);
useEffect(() => {
if (accessToken) fetchSummary();
return () => inFlight.current?.abort();
}, [accessToken, fetchSummary]);
return { summary, loading, error, reload: fetchSummary };
}

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVatRates } from './getTaxes' import { useVatRates } from './getTaxes'
import { useAdminInvoices, type AdminInvoice } from './getInvoices' import { useAdminInvoiceRevenueSummary, useAdminInvoices, type AdminInvoice } from './getInvoices'
import useAuthStore from '../../../store/authStore' import useAuthStore from '../../../store/authStore'
import { useTranslation } from '../../../i18n/useTranslation' import { useTranslation } from '../../../i18n/useTranslation'
@ -60,6 +60,17 @@ export function useFinanceManagementPageState() {
limit: 200, limit: 200,
offset: 0, offset: 0,
}) })
const {
summary: revenueSummary,
loading: revenueLoading,
error: revenueError,
reload: reloadRevenueSummary,
} = useAdminInvoiceRevenueSummary()
const combinedInvoiceError = invError || (revenueError ? `Revenue summary: ${revenueError}` : '')
const reloadFinanceData = useCallback(async () => {
await Promise.all([reload(), reloadRevenueSummary()])
}, [reload, reloadRevenueSummary])
const totals = useMemo(() => { const totals = useMemo(() => {
const now = new Date() const now = new Date()
@ -79,10 +90,10 @@ export function useFinanceManagementPageState() {
}) })
return { return {
totalAll: invoices.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0), totalAll: Number(revenueSummary?.totalPaidAllTime ?? 0),
totalRange: range.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0), totalRange: range.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0),
} }
}, [invoices, timeframe]) }, [invoices, revenueSummary, timeframe])
const filteredBills = useMemo(() => { const filteredBills = useMemo(() => {
const query = billFilter.query.trim().toLowerCase() const query = billFilter.query.trim().toLowerCase()
@ -323,9 +334,9 @@ export function useFinanceManagementPageState() {
reportMsg, reportMsg,
setReportMsg, setReportMsg,
invoices, invoices,
invLoading, invLoading: invLoading || revenueLoading,
invError, invError: combinedInvoiceError,
reload, reload: reloadFinanceData,
totals, totals,
filteredBills, filteredBills,
exportBills, exportBills,

View File

@ -333,12 +333,14 @@ export default function FinanceManagementPage() {
if (!invoice.due_at) return <span className="text-slate-400"></span> if (!invoice.due_at) return <span className="text-slate-400"></span>
const due = new Date(invoice.due_at) const due = new Date(invoice.due_at)
const now = new Date() const today = new Date()
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) due.setHours(0, 0, 0, 0)
today.setHours(0, 0, 0, 0)
const diffDays = Math.round((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
let cls = 'bg-green-100 text-green-700' let cls = 'bg-green-100 text-green-700'
if (invoice.status === 'paid') cls = 'bg-green-100 text-green-700' if (invoice.status === 'paid') cls = 'bg-green-100 text-green-700'
else if (diffDays < 0) cls = 'bg-red-100 text-red-700' else if (invoice.status === 'overdue' || diffDays < 0) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700' else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700' else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'

View File

@ -64,7 +64,7 @@ export default function ConfirmActionModal({
return ( return (
<Transition show={open} as={Fragment}> <Transition show={open} as={Fragment}>
<Dialog onClose={pending ? () => {} : onClose} className="relative z-[1100]"> <Dialog onClose={pending ? () => {} : onClose} className="relative z-1300">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="transition-opacity ease-out duration-200" enter="transition-opacity ease-out duration-200"

View File

@ -1106,7 +1106,7 @@ export const de: Translations = {
"k047a175d": "Keine Verträge gefunden.", "k047a175d": "Keine Verträge gefunden.",
"k06d4487f": "Bearbeitung abbrechen", "k06d4487f": "Bearbeitung abbrechen",
"k0853cfa6": "Vielen Dank für Ihr Abonnement!", "k0853cfa6": "Vielen Dank für Ihr Abonnement!",
"k096f4013": "Verwalten Sie Ihre Unternehmensstempel. Nur einer kann gleichzeitig aktiv sein.", "k096f4013": "Verwalten Sie Ihren aktiven Unternehmensstempel. Nur einer kann gleichzeitig aktiv sein.",
"k0af6c6be": "Erstellen & Aktivieren", "k0af6c6be": "Erstellen & Aktivieren",
"k0affa826": "Wird Nutzern im Shop und beim Checkout angezeigt.", "k0affa826": "Wird Nutzern im Shop und beim Checkout angezeigt.",
"k0a50d234": " fehlende Schlüssel.", "k0a50d234": " fehlende Schlüssel.",
@ -1132,7 +1132,7 @@ export const de: Translations = {
"k2e43a9c4": "Klicken oder neues Bild hierher ziehen", "k2e43a9c4": "Klicken oder neues Bild hierher ziehen",
"k3466b0e0": "Zahlungsmethode", "k3466b0e0": "Zahlungsmethode",
"k346a2c64": "Sprachverwaltung", "k346a2c64": "Sprachverwaltung",
"k39791457": "Vertragsvorlagen, Unternehmensstempel verwalten und neue Vorlagen erstellen.", "k39791457": "Vertragsvorlagen, Unternehmensdetails verwalten und neue Vorlagen erstellen.",
"k41ab9eb6": "Das Bild kann nach dem Hochladen zugeschnitten und angepasst werden", "k41ab9eb6": "Das Bild kann nach dem Hochladen zugeschnitten und angepasst werden",
"k41afd863": "Bearbeitung:", "k41afd863": "Bearbeitung:",
"k4aeb8688": "2. Ihre Auswahl", "k4aeb8688": "2. Ihre Auswahl",
@ -1176,7 +1176,7 @@ export const de: Translations = {
"k9c1a5ecc": "Felder mit angemeldeten Daten befüllen", "k9c1a5ecc": "Felder mit angemeldeten Daten befüllen",
"ka3ee9ded": "Abonnement-Abrechnung", "ka3ee9ded": "Abonnement-Abrechnung",
"ka56b7b2b": "Keine PDF-Vorschau verfügbar.", "ka56b7b2b": "Keine PDF-Vorschau verfügbar.",
"ka5f38d19": "Unternehmensstempel", "ka5f38d19": "Unternehmensdetails",
"ka802064d": "i18n-Korrekturen werden auf Client-Komponenten angewendet und Übersetzungsdateien aktualisiert...", "ka802064d": "i18n-Korrekturen werden auf Client-Komponenten angewendet und Übersetzungsdateien aktualisiert...",
"kaa30f0cd": "Kaffee erstellen", "kaa30f0cd": "Kaffee erstellen",
"kaa8bbc8e": "Unternehmensinformationen", "kaa8bbc8e": "Unternehmensinformationen",
@ -1191,6 +1191,18 @@ export const de: Translations = {
"kb8f33873": "Übersetzungsfortschritt", "kb8f33873": "Übersetzungsfortschritt",
"kb9e483c4": "Kaffeedetails aktualisieren.", "kb9e483c4": "Kaffeedetails aktualisieren.",
"k644d9ea8": "Überschreibung zurücksetzen", "k644d9ea8": "Überschreibung zurücksetzen",
"k0198ce13": "Firmenlogo",
"k03d7361d": "Laden Sie das Logo hoch, das auf Firmenrechnungen angezeigt wird.",
"k089f42a1": "Logo auswählen",
"k0d8e2d01": "Logo entfernen",
"k1c2b0975": "PNG, JPEG, WebP oder SVG bis 1 MB.",
"k2bd38d5e": "Ungültiges Logoformat. Erlaubt: PNG, JPEG, WebP, SVG.",
"k394b7f42": "Die Logodatei ist zu groß. Maximal 1 MB.",
"k432b8a12": "Noch kein Logo hochgeladen.",
"k54d7cbef": "Unternehmensstempel",
"k7d3f0e11": "Logo ersetzen",
"k8a1d4c20": "Die Logodatei konnte nicht gelesen werden.",
"k95a16b2b": "Unternehmensdetails konnten nicht gespeichert werden.",
"kba6bd6f3": "oder zum Durchsuchen klicken", "kba6bd6f3": "oder zum Durchsuchen klicken",
"kcc4adbcc": "Navigations-Schnellzugriff", "kcc4adbcc": "Navigations-Schnellzugriff",
"kce094582": "Rechnungsadresse", "kce094582": "Rechnungsadresse",
@ -1897,13 +1909,10 @@ export const de: Translations = {
"kfd1e0089": "Automatisches Scrollen beim Öffnen des Panels", "kfd1e0089": "Automatisches Scrollen beim Öffnen des Panels",
"k23e95df1": "Automatisches Scrollen beim speichern", "k23e95df1": "Automatisches Scrollen beim speichern",
"k429d94bf": "Nach Vorlagenfamilie geordnet", "k429d94bf": "Nach Vorlagenfamilie geordnet",
"k61d66984": "Gruppierte Bibliothek",
"k66b39536": "Vorlagenübersicht", "k66b39536": "Vorlagenübersicht",
"k74823841": "Stärkeres Sprachwechseln",
"k766a5504": "Verträge sind wieder nach Typ gruppiert. Die Sprache befindet sich jetzt im Hauptkopfbereich jeder Spur als direkter Selektor, sodass Sie schneller wechseln können, ohne in verschachtelte Karten zu navigieren.", "k766a5504": "Verträge sind wieder nach Typ gruppiert. Die Sprache befindet sich jetzt im Hauptkopfbereich jeder Spur als direkter Selektor, sodass Sie schneller wechseln können, ohne in verschachtelte Karten zu navigieren.",
"k7e4ef084": "Springen Sie zwischen Vorlagenfamilien, sehen Sie die aktuell aktiven Versionen sofort und behalten Sie sprachspezifische Revisionen an einem Ort.", "k7e4ef084": "Springen Sie zwischen Vorlagenfamilien, sehen Sie die aktuell aktiven Versionen sofort und behalten Sie sprachspezifische Revisionen an einem Ort.",
"k8351e02f": "Admin-Arbeitsbereich", "k8351e02f": "Admin-Arbeitsbereich",
"kccff045c": "Schnellerer Bearbeitungsablauf",
"kf962066f": "Vertragstyp", "kf962066f": "Vertragstyp",
"kb1cf599b": "Zählt als globaler Schlüssel", "kb1cf599b": "Zählt als globaler Schlüssel",
"k1c7ec4f2": "Admin Fetch Log", "k1c7ec4f2": "Admin Fetch Log",

View File

@ -1106,7 +1106,7 @@ export const en: Translations = {
"k047a175d": "No contracts found.", "k047a175d": "No contracts found.",
"k06d4487f": "Cancel editing", "k06d4487f": "Cancel editing",
"k0853cfa6": "Thanks for your subscription!", "k0853cfa6": "Thanks for your subscription!",
"k096f4013": "Manage your company stamps. One active at a time.", "k096f4013": "Manage your active company stamp. One active at a time.",
"k0af6c6be": "Create & Activate", "k0af6c6be": "Create & Activate",
"k0affa826": "Shown to users in the shop and checkout.", "k0affa826": "Shown to users in the shop and checkout.",
"k0a50d234": " missing keys.", "k0a50d234": " missing keys.",
@ -1132,7 +1132,7 @@ export const en: Translations = {
"k2e43a9c4": "Click or drag and drop a new image here", "k2e43a9c4": "Click or drag and drop a new image here",
"k3466b0e0": "Payment method", "k3466b0e0": "Payment method",
"k346a2c64": "Language Management", "k346a2c64": "Language Management",
"k39791457": "Manage contract templates, company stamp, and create new templates.", "k39791457": "Manage contract templates, company details, and create new templates.",
"k41ab9eb6": "You'll be able to crop and adjust the image after uploading", "k41ab9eb6": "You'll be able to crop and adjust the image after uploading",
"k41afd863": "Editing:", "k41afd863": "Editing:",
"k4aeb8688": "2. Your selection", "k4aeb8688": "2. Your selection",
@ -1176,7 +1176,7 @@ export const en: Translations = {
"k9c1a5ecc": "Fill fields with logged in data", "k9c1a5ecc": "Fill fields with logged in data",
"ka3ee9ded": "Subscription Billing", "ka3ee9ded": "Subscription Billing",
"ka56b7b2b": "No PDF preview available.", "ka56b7b2b": "No PDF preview available.",
"ka5f38d19": "Company Stamp", "ka5f38d19": "Company Details",
"ka802064d": "Applying i18n auto-fixes to client components and updating translation files...", "ka802064d": "Applying i18n auto-fixes to client components and updating translation files...",
"kaa30f0cd": "Create Coffee", "kaa30f0cd": "Create Coffee",
"kaa8bbc8e": "Company Information", "kaa8bbc8e": "Company Information",
@ -1191,6 +1191,18 @@ export const en: Translations = {
"kb8f33873": "Translation progress", "kb8f33873": "Translation progress",
"kb9e483c4": "Update details of the coffee.", "kb9e483c4": "Update details of the coffee.",
"k644d9ea8": "Revert override", "k644d9ea8": "Revert override",
"k0198ce13": "Company logo",
"k03d7361d": "Upload the logo shown on company invoices.",
"k089f42a1": "Choose logo",
"k0d8e2d01": "Remove logo",
"k1c2b0975": "PNG, JPEG, WebP or SVG up to 1 MB.",
"k2bd38d5e": "Invalid logo format. Allowed: PNG, JPEG, WebP, SVG.",
"k394b7f42": "Logo file is too large. Max 1 MB.",
"k432b8a12": "No logo uploaded yet.",
"k54d7cbef": "Company stamp",
"k7d3f0e11": "Replace logo",
"k8a1d4c20": "Could not read logo file.",
"k95a16b2b": "Could not save company details.",
"kba6bd6f3": "or click to browse", "kba6bd6f3": "or click to browse",
"kcc4adbcc": "Navigation shortcuts", "kcc4adbcc": "Navigation shortcuts",
"kce094582": "Invoice address", "kce094582": "Invoice address",
@ -1897,13 +1909,10 @@ export const en: Translations = {
"kfd1e0089": "Auto-scroll on panel open", "kfd1e0089": "Auto-scroll on panel open",
"k23e95df1": "Auto-scroll on save", "k23e95df1": "Auto-scroll on save",
"k429d94bf": "Organized by template family", "k429d94bf": "Organized by template family",
"k61d66984": "Grouped library",
"k66b39536": "Template overview", "k66b39536": "Template overview",
"k74823841": "Stronger language switching",
"k766a5504": "Contracts are grouped by type again. Language now sits in the main header area of each track as a direct selector, so you can switch faster without digging into nested cards.", "k766a5504": "Contracts are grouped by type again. Language now sits in the main header area of each track as a direct selector, so you can switch faster without digging into nested cards.",
"k7e4ef084": "Jump between template families, spot the currently active versions immediately and keep language-specific revisions in one place.", "k7e4ef084": "Jump between template families, spot the currently active versions immediately and keep language-specific revisions in one place.",
"k8351e02f": "Admin workspace", "k8351e02f": "Admin workspace",
"kccff045c": "Faster edit flow",
"kf962066f": "Contract type", "kf962066f": "Contract type",
"kb1cf599b": "Counts as global key", "kb1cf599b": "Counts as global key",
"k1c7ec4f2": "Admin Fetch Log", "k1c7ec4f2": "Admin Fetch Log",