feat: update contract and template management to streamline type handling and permissions

This commit is contained in:
seaznCode 2026-03-11 22:35:24 +01:00
parent 48c63a896f
commit f94e4669f8
4 changed files with 62 additions and 54 deletions

View File

@ -18,7 +18,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
const [statusMsg, setStatusMsg] = useState<string | null>(null); const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [lang, setLang] = useState<'en' | 'de'>('en'); const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<'contract' | 'bill' | 'invoice' | 'other'>('contract'); const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract');
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract'); const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
@ -54,7 +54,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
setHtmlCode(tpl.html || ''); setHtmlCode(tpl.html || '');
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
setLang((tpl.lang as any) || 'en'); setLang((tpl.lang as any) || 'en');
setType(((tpl.type as any) || 'contract') as 'contract' | 'bill' | 'invoice' | 'other'); setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr'); setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({ setEditingMeta({
@ -214,8 +214,12 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
}; };
const save = async (publish: boolean) => { const save = async (publish: boolean) => {
if (publish && type === 'contract') { if (publish) {
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract'; let kind = type === 'contract'
? (contractType === 'gdpr' ? 'GDPR' : 'Contract')
: type === 'invoice'
? 'Invoice'
: 'Other';
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`) setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`)
setPublishConfirmOpen(true) setPublishConfirmOpen(true)
return return
@ -283,12 +287,15 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'invoice' | 'other')} onChange={(e) => {
const newType = e.target.value as 'contract' | 'invoice' | 'other';
setType(newType);
if (newType === 'invoice') setUserType('both');
}}
required required
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow" className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
> >
<option value="contract">Contract</option> <option value="contract">Contract</option>
<option value="bill">Bill</option>
<option value="invoice">Invoice</option> <option value="invoice">Invoice</option>
<option value="other">Other</option> <option value="other">Other</option>
</select> </select>
@ -303,6 +310,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<option value="gdpr">GDPR</option> <option value="gdpr">GDPR</option>
</select> </select>
)} )}
{type !== 'invoice' && (
<select <select
value={userType} value={userType}
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')} onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
@ -313,6 +321,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<option value="company">Company</option> <option value="company">Company</option>
<option value="both">Both</option> <option value="both">Both</option>
</select> </select>
)}
<select <select
value={lang} value={lang}
onChange={(e) => setLang(e.target.value as 'en' | 'de')} onChange={(e) => setLang(e.target.value as 'en' | 'de')}

View File

@ -98,8 +98,12 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const target = current === 'published' ? 'inactive' : 'active'; const target = current === 'published' ? 'inactive' : 'active';
if (target === 'active') { if (target === 'active') {
const tpl = items.find((i) => i.id === id); const tpl = items.find((i) => i.id === id);
if (tpl?.type === 'contract') { if (tpl) {
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract'; const kind = tpl.type === 'contract'
? (tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract')
: tpl.type === 'invoice'
? 'Invoice'
: 'Other';
setPendingToggle({ setPendingToggle({
id, id,
target, target,
@ -137,7 +141,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"> <div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
For invoice emails, provide active invoice templates for the language/user type combinations you need (en/de × personal/company/both). If no active invoice template matches, backend falls back to text-only email. Invoice templates always use user type &quot;Both&quot;. Provide templates for each language (en/de). If no active invoice template matches, backend falls back to a text-only invoice.
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input <input
@ -158,12 +162,12 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((c) => ( {filtered.map((c) => (
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition"> <div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition">
<div className="flex items-center gap-2">
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p> <p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={c.status} /> <StatusBadge status={c.status} />
{c.type && ( {c.type && (
<Pill className="bg-slate-50 text-slate-800 border-slate-200"> <Pill className="bg-slate-50 text-slate-800 border-slate-200">
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : c.type === 'invoice' ? 'Invoice' : 'Other'} {c.type === 'contract' ? 'Contract' : c.type === 'invoice' ? 'Invoice' : 'Other'}
</Pill> </Pill>
)} )}
{c.type === 'contract' && ( {c.type === 'contract' && (
@ -171,7 +175,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'} {c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
</Pill> </Pill>
)} )}
{c.user_type && ( {c.user_type && c.type !== 'invoice' && (
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200"> <Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'} {c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
</Pill> </Pill>

View File

@ -327,30 +327,7 @@ export default function SummaryPage() {
> >
Fill fields with logged in data Fill fields with logged in data
</button> </button>
<div className="mb-4 grid gap-3 sm:grid-cols-2"> {/* "For someone else" is disabled for now — only self-subscriptions */}
<button
type="button"
onClick={() => setIsForSelf(true)}
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
isForSelf
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
For me
</button>
<button
type="button"
onClick={() => setIsForSelf(false)}
className={`rounded-md border px-3 py-2 text-sm font-medium transition ${
!isForSelf
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
For someone else
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */} {/* inputs translated */}
<div> <div>

View File

@ -80,6 +80,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
: 'relative' : 'relative'
const [hasReferralPerm, setHasReferralPerm] = useState(false) const [hasReferralPerm, setHasReferralPerm] = useState(false)
const [hasSubscribePerm, setHasSubscribePerm] = useState(false)
const [canSeeDashboard, setCanSeeDashboard] = useState(false) const [canSeeDashboard, setCanSeeDashboard] = useState(false)
const headerElRef = useRef<HTMLElement | null>(null) const headerElRef = useRef<HTMLElement | null>(null)
@ -169,14 +170,20 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
} }
if (!user) { if (!user) {
console.log(' Header: no user, clearing permission flag') console.log(' Header: no user, clearing permission flag')
if (!cancelled) setHasReferralPerm(false) if (!cancelled) {
setHasReferralPerm(false)
setHasSubscribePerm(false)
}
return return
} }
const uid = (user as any)?.id ?? (user as any)?._id ?? (user as any)?.userId const uid = (user as any)?.id ?? (user as any)?._id ?? (user as any)?.userId
if (!uid) { if (!uid) {
console.warn('⚠️ Header: user id missing, cannot fetch permissions', user) console.warn('⚠️ Header: user id missing, cannot fetch permissions', user)
if (!cancelled) setHasReferralPerm(false) if (!cancelled) {
setHasReferralPerm(false)
setHasSubscribePerm(false)
}
return return
} }
@ -216,20 +223,31 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
body body
let can = false let can = false
let canSub = false
if (Array.isArray(permsSrc)) { if (Array.isArray(permsSrc)) {
// Could be array of strings or objects // Could be array of strings or objects
can = can =
permsSrc.includes?.('can_create_referrals') || permsSrc.includes?.('can_create_referrals') ||
permsSrc.some?.((p: any) => p?.name === 'can_create_referrals' || p?.key === 'can_create_referrals') permsSrc.some?.((p: any) => p?.name === 'can_create_referrals' || p?.key === 'can_create_referrals')
canSub =
permsSrc.includes?.('can_subscribe') ||
permsSrc.some?.((p: any) => p?.name === 'can_subscribe' || p?.key === 'can_subscribe')
} else if (permsSrc && typeof permsSrc === 'object') { } else if (permsSrc && typeof permsSrc === 'object') {
can = !!permsSrc.can_create_referrals can = !!permsSrc.can_create_referrals
canSub = !!permsSrc.can_subscribe
} }
console.log('✅ Header: can_create_referrals =', can) console.log('✅ Header: can_create_referrals =', can, 'can_subscribe =', canSub)
if (!cancelled) setHasReferralPerm(!!can) if (!cancelled) {
setHasReferralPerm(!!can)
setHasSubscribePerm(!!canSub)
}
} catch (e) { } catch (e) {
console.error('❌ Header: fetch permissions error:', e) console.error('❌ Header: fetch permissions error:', e)
if (!cancelled) setHasReferralPerm(false) if (!cancelled) {
setHasReferralPerm(false)
setHasSubscribePerm(false)
}
} }
} }
@ -487,7 +505,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
</button> </button>
)} )}
{DISPLAY_ABONEMENTS && ( {DISPLAY_ABONEMENTS && hasSubscribePerm && (
<button <button
onClick={() => router.push('/coffee-abonnements')} onClick={() => router.push('/coffee-abonnements')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
@ -719,7 +737,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
Personal Matrix Personal Matrix
</button> </button>
)} )}
{DISPLAY_ABONEMENTS && ( {DISPLAY_ABONEMENTS && hasSubscribePerm && (
<button <button
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }} onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left" className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"