Merge pull request 'feature+refactor/fewThingsIGuess' (#22) from feature+refactor/fewThingsIGuess into dev
Reviewed-on: #22
This commit is contained in:
commit
aa62ad9c15
@ -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
|
||||||
|
|
||||||
|
type CompanySettingsForm = {
|
||||||
|
company_name: string
|
||||||
|
company_street: string
|
||||||
|
company_postal_city: string
|
||||||
|
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()
|
const reader = new FileReader()
|
||||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result
|
const result = typeof reader.result === 'string' ? reader.result : ''
|
||||||
if (typeof result === 'string') resolve(result)
|
const match = result.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/)
|
||||||
else reject(new Error('Invalid file result'))
|
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>
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k33918465')}</label>
|
<div className="space-y-1">
|
||||||
<input
|
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.k0198ce13')}</h3>
|
||||||
type="text"
|
<p className="text-sm text-slate-500">{t('autofix.k03d7361d')}</p>
|
||||||
id="company_name"
|
<p className="text-xs text-slate-400">{t('autofix.k1c2b0975')}</p>
|
||||||
name="company_name"
|
</div>
|
||||||
value={form.company_name}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
onChange={handleChange}
|
<input
|
||||||
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"
|
ref={logoInputRef}
|
||||||
placeholder={t('autofix.k91e69df1')}
|
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>
|
||||||
<div>
|
|
||||||
<label htmlFor="company_street" className="block text-sm font-medium text-gray-700 mb-1">
|
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 bg-white p-4">
|
||||||
Street
|
{logoPreviewSrc ? (
|
||||||
</label>
|
<img
|
||||||
<input
|
src={logoPreviewSrc}
|
||||||
type="text"
|
alt={t('autofix.k0198ce13')}
|
||||||
id="company_street"
|
className="max-h-24 max-w-full object-contain"
|
||||||
name="company_street"
|
/>
|
||||||
value={form.company_street}
|
) : (
|
||||||
onChange={handleChange}
|
<div className="text-sm text-slate-500">{t('autofix.k432b8a12')}</div>
|
||||||
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"
|
)}
|
||||||
placeholder={t('autofix.k81c7c2f2')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label htmlFor="company_postal_city" className="block text-sm font-medium text-gray-700 mb-1">
|
{logoError && <div className="mt-3 text-sm font-medium text-red-600">{logoError}</div>}
|
||||||
Postal Code & City
|
</div>
|
||||||
</label>
|
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||||
id="company_postal_city"
|
<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>
|
||||||
name="company_postal_city"
|
{t('autofix.kaa8bbc8e')}
|
||||||
value={form.company_postal_city}
|
</h3>
|
||||||
onChange={handleChange}
|
<p className="mb-4 text-sm text-slate-500">{t('autofix.k15bea9bb')}</p>
|
||||||
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"
|
|
||||||
placeholder={t('autofix.k93165aea')}
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
/>
|
<div>
|
||||||
</div>
|
<label htmlFor="company_name" className="mb-1 block text-sm font-medium text-gray-700">{t('autofix.k33918465')}</label>
|
||||||
<div>
|
<input
|
||||||
<label htmlFor="company_country" className="block text-sm font-medium text-gray-700 mb-1">
|
type="text"
|
||||||
Country
|
id="company_name"
|
||||||
</label>
|
name="company_name"
|
||||||
<input
|
value={form.company_name}
|
||||||
type="text"
|
onChange={handleChange}
|
||||||
id="company_country"
|
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"
|
||||||
name="company_country"
|
placeholder={t('autofix.k91e69df1')}
|
||||||
value={form.company_country}
|
/>
|
||||||
onChange={handleChange}
|
</div>
|
||||||
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"
|
<div>
|
||||||
placeholder="Germany"
|
<label htmlFor="company_street" className="mb-1 block text-sm font-medium text-gray-700">Street</label>
|
||||||
/>
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company_street"
|
||||||
|
name="company_street"
|
||||||
|
value={form.company_street}
|
||||||
|
onChange={handleChange}
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company_postal_city" className="mb-1 block text-sm font-medium text-gray-700">Postal Code & City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company_postal_city"
|
||||||
|
name="company_postal_city"
|
||||||
|
value={form.company_postal_city}
|
||||||
|
onChange={handleChange}
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company_country" className="mb-1 block text-sm font-medium text-gray-700">Country</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company_country"
|
||||||
|
name="company_country"
|
||||||
|
value={form.company_country}
|
||||||
|
onChange={handleChange}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{saveError && <div className="text-sm font-medium text-red-600">{saveError}</div>}
|
||||||
<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>
|
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'}
|
<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'}
|
||||||
|
</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>
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'
|
||||||
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
type Props = {
|
|
||||||
selectedPlanCapsules: number;
|
|
||||||
shippingLoading: boolean;
|
|
||||||
isFreeShippingSelected: boolean;
|
|
||||||
selectedShippingFee: number;
|
|
||||||
shippingError: string | null;
|
|
||||||
onDecrease: () => void;
|
|
||||||
onIncrease: () => void;
|
|
||||||
loadingText: string;
|
|
||||||
freeShippingText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlanSelectorCard({
|
|
||||||
selectedPlanCapsules,
|
|
||||||
shippingLoading,
|
|
||||||
isFreeShippingSelected,
|
|
||||||
selectedShippingFee,
|
|
||||||
shippingError,
|
|
||||||
onDecrease,
|
|
||||||
onIncrease,
|
|
||||||
loadingText,
|
|
||||||
freeShippingText,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDecrease}
|
|
||||||
disabled={selectedPlanCapsules <= 60}
|
|
||||||
className="h-10 w-10 rounded-full bg-slate-100 hover:bg-slate-200 text-lg font-bold transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 text-center min-w-[190px]">
|
|
||||||
<div className="text-2xl font-extrabold text-slate-900">{selectedPlanCapsules} pcs</div>
|
|
||||||
<div className="text-xs text-slate-500">{selectedPlanCapsules / 10} packs of 10 · min. 60</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onIncrease}
|
|
||||||
className="h-10 w-10 rounded-full bg-slate-100 hover:bg-slate-200 text-lg font-bold transition flex items-center justify-center"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<div className="ml-auto">
|
|
||||||
{shippingLoading ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-slate-100 text-slate-700">{loadingText}</span>
|
|
||||||
) : isFreeShippingSelected ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">{freeShippingText}</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-200">Shipping EUR {selectedShippingFee.toFixed(2)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shippingError && (
|
|
||||||
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
|
|
||||||
Shipping fees could not be loaded: {shippingError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user