789 lines
33 KiB
TypeScript
789 lines
33 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { useAlert } from '../context/AlertContext'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import Forbidden from '../components/Forbidden'
|
|
import FormField from '../components/FormField'
|
|
import { motion } from 'framer-motion'
|
|
|
|
import apiFetch from '../utils/api'
|
|
const API_BASE = '/api/admin'
|
|
|
|
const DEFAULT_FIELD_ORDER = ['street', 'city_postal', 'country', 'company_id', 'vat_id']
|
|
|
|
const FIELD_LABELS: Record<string, string> = {
|
|
street: 'Ulice',
|
|
city_postal: 'Město + PSČ',
|
|
country: 'Země',
|
|
company_id: 'IČO',
|
|
vat_id: 'DIČ',
|
|
}
|
|
|
|
const currentYear = new Date().getFullYear().toString().slice(-2)
|
|
|
|
interface CustomField {
|
|
name: string
|
|
value: string
|
|
showLabel: boolean
|
|
_key: string
|
|
}
|
|
|
|
interface CompanyForm {
|
|
company_name: string
|
|
street: string
|
|
city: string
|
|
postal_code: string
|
|
country: string
|
|
company_id: string
|
|
vat_id: string
|
|
quotation_prefix: string
|
|
default_currency: string
|
|
default_vat_rate: number
|
|
order_type_code: string
|
|
invoice_type_code: string
|
|
}
|
|
|
|
interface BankAccount {
|
|
id: number
|
|
account_name: string
|
|
bank_name: string
|
|
account_number: string
|
|
iban: string
|
|
bic: string
|
|
currency: string
|
|
is_default: boolean
|
|
}
|
|
|
|
interface BankForm {
|
|
account_name: string
|
|
bank_name: string
|
|
account_number: string
|
|
iban: string
|
|
bic: string
|
|
currency: string
|
|
is_default: boolean
|
|
}
|
|
|
|
export default function CompanySettings() {
|
|
const alert = useAlert()
|
|
const { hasPermission } = useAuth()
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [uploadingLogo, setUploadingLogo] = useState(false)
|
|
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
|
const logoUrlRef = useRef<string | null>(null)
|
|
const [form, setForm] = useState<CompanyForm>({
|
|
company_name: '',
|
|
street: '',
|
|
city: '',
|
|
postal_code: '',
|
|
country: '',
|
|
company_id: '',
|
|
vat_id: '',
|
|
quotation_prefix: 'N',
|
|
default_currency: 'EUR',
|
|
default_vat_rate: 21,
|
|
order_type_code: '71',
|
|
invoice_type_code: '81',
|
|
})
|
|
const [customFields, setCustomFields] = useState<CustomField[]>([])
|
|
const customFieldKeyCounter = useRef(0)
|
|
const [fieldOrder, setFieldOrder] = useState<string[]>([...DEFAULT_FIELD_ORDER])
|
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
|
|
const [bankLoading, setBankLoading] = useState(true)
|
|
const [bankSaving, setBankSaving] = useState(false)
|
|
const [editingBank, setEditingBank] = useState<number | null>(null)
|
|
const [bankForm, setBankForm] = useState<BankForm>({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
|
|
|
|
const getFullFieldOrder = useCallback((): string[] => {
|
|
const allBuiltIn = [...DEFAULT_FIELD_ORDER]
|
|
const order = [...fieldOrder].filter(k => k !== 'company_name')
|
|
for (const f of allBuiltIn) {
|
|
if (!order.includes(f)) order.push(f)
|
|
}
|
|
for (let i = 0; i < customFields.length; i++) {
|
|
const key = `custom_${i}`
|
|
if (!order.includes(key)) order.push(key)
|
|
}
|
|
return order.filter(key => {
|
|
if (key.startsWith('custom_')) {
|
|
const idx = parseInt(key.split('_')[1])
|
|
return idx < customFields.length
|
|
}
|
|
return true
|
|
})
|
|
}, [fieldOrder, customFields])
|
|
|
|
const moveField = (index: number, direction: number) => {
|
|
const order = getFullFieldOrder()
|
|
const newIndex = index + direction
|
|
if (newIndex < 0 || newIndex >= order.length) return
|
|
const updated = [...order]
|
|
;[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]
|
|
setFieldOrder(updated)
|
|
}
|
|
|
|
const getFieldDisplayName = (key: string): string => {
|
|
if (FIELD_LABELS[key]) return FIELD_LABELS[key]
|
|
if (key.startsWith('custom_')) {
|
|
const idx = parseInt(key.split('_')[1])
|
|
const cf = customFields[idx]
|
|
if (cf) return cf.name ? `${cf.name}: ${cf.value || '...'}` : cf.value || `Vlastní pole ${idx + 1}`
|
|
}
|
|
return key
|
|
}
|
|
|
|
const fetchLogo = useCallback(async () => {
|
|
try {
|
|
const resp = await apiFetch(`${API_BASE}/company-settings/logo`)
|
|
if (resp.ok) {
|
|
const blob = await resp.blob()
|
|
setLogoUrl(prev => {
|
|
if (prev) URL.revokeObjectURL(prev)
|
|
const url = URL.createObjectURL(blob)
|
|
logoUrlRef.current = url
|
|
return url
|
|
})
|
|
}
|
|
} catch {
|
|
// ignore - no logo
|
|
}
|
|
}, [])
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/company-settings`)
|
|
if (response.status === 401) return
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
const d = result.data
|
|
setForm({
|
|
company_name: d.company_name || '',
|
|
street: d.street || '',
|
|
city: d.city || '',
|
|
postal_code: d.postal_code || '',
|
|
country: d.country || '',
|
|
company_id: d.company_id || '',
|
|
vat_id: d.vat_id || '',
|
|
quotation_prefix: d.quotation_prefix || 'N',
|
|
default_currency: d.default_currency || 'EUR',
|
|
default_vat_rate: d.default_vat_rate || 21,
|
|
order_type_code: d.order_type_code || '71',
|
|
invoice_type_code: d.invoice_type_code || '81',
|
|
})
|
|
const cf = Array.isArray(d.custom_fields) && d.custom_fields.length > 0
|
|
? d.custom_fields.map((f: { name: string; value: string; showLabel?: boolean }) => ({ ...f, _key: `cf-${++customFieldKeyCounter.current}` }))
|
|
: []
|
|
setCustomFields(cf)
|
|
if (Array.isArray(d.supplier_field_order) && d.supplier_field_order.length > 0) {
|
|
setFieldOrder(d.supplier_field_order)
|
|
} else {
|
|
setFieldOrder([...DEFAULT_FIELD_ORDER])
|
|
}
|
|
if (d.has_logo) {
|
|
fetchLogo()
|
|
}
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se načíst nastavení')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [alert, fetchLogo])
|
|
|
|
const fetchBankAccounts = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/bank-accounts`)
|
|
if (response.status === 401) return
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
setBankAccounts(result.data)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setBankLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const resetBankForm = () => {
|
|
setEditingBank(null)
|
|
setBankForm({ account_name: '', bank_name: '', account_number: '', iban: '', bic: '', currency: 'CZK', is_default: false })
|
|
}
|
|
|
|
const handleBankSave = async () => {
|
|
if (!bankForm.account_name.trim()) {
|
|
alert.error('Název účtu je povinný')
|
|
return
|
|
}
|
|
setBankSaving(true)
|
|
try {
|
|
const isEdit = editingBank !== null
|
|
const url = isEdit ? `${API_BASE}/bank-accounts/${editingBank}` : `${API_BASE}/bank-accounts`
|
|
const response = await apiFetch(url, {
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(bankForm)
|
|
})
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message)
|
|
resetBankForm()
|
|
fetchBankAccounts()
|
|
} else {
|
|
alert.error(result.error || 'Chyba při ukládání')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setBankSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleBankDelete = async (id: number) => {
|
|
if (!confirm('Opravdu smazat tento bankovní účet?')) return
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, { method: 'DELETE' })
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message)
|
|
if (editingBank === id) resetBankForm()
|
|
fetchBankAccounts()
|
|
} else {
|
|
alert.error(result.error || 'Chyba při mazání')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
}
|
|
}
|
|
|
|
const startEditBank = (account: BankAccount) => {
|
|
setEditingBank(account.id)
|
|
setBankForm({
|
|
account_name: account.account_name || '',
|
|
bank_name: account.bank_name || '',
|
|
account_number: account.account_number || '',
|
|
iban: account.iban || '',
|
|
bic: account.bic || '',
|
|
currency: account.currency || 'CZK',
|
|
is_default: !!account.is_default
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
fetchBankAccounts()
|
|
}, [fetchData, fetchBankAccounts])
|
|
|
|
// Cleanup blob URL on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (logoUrlRef.current) URL.revokeObjectURL(logoUrlRef.current)
|
|
}
|
|
}, [])
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const payload = {
|
|
...form,
|
|
custom_fields: customFields.filter(f => f.name.trim() || f.value.trim()),
|
|
supplier_field_order: getFullFieldOrder(),
|
|
}
|
|
const response = await apiFetch(`${API_BASE}/company-settings`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message || 'Nastavení bylo uloženo')
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se uložit nastavení')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
setUploadingLogo(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('logo', file)
|
|
|
|
const response = await apiFetch(`${API_BASE}/company-settings/logo`, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
const result = await response.json()
|
|
if (result.success) {
|
|
alert.success(result.message || 'Logo bylo nahráno')
|
|
fetchLogo()
|
|
} else {
|
|
alert.error(result.error || 'Nepodařilo se nahrát logo')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
} finally {
|
|
setUploadingLogo(false)
|
|
e.target.value = ''
|
|
}
|
|
}
|
|
|
|
const updateField = (field: keyof CompanyForm, value: string | number) => {
|
|
setForm(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
if (!hasPermission('offers.settings')) return <Forbidden />
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
|
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
|
<div>
|
|
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
|
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
|
</div>
|
|
<div className="admin-skeleton-line h-10" style={{ width: '120px', borderRadius: '8px' }} />
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1.25rem' }}>
|
|
{[0, 1, 2, 3, 4, 5].map(i => (
|
|
<div key={i} className="admin-card">
|
|
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
|
<div className="admin-skeleton-line h-8" style={{ width: '60%' }} />
|
|
{[0, 1, 2].map(j => (
|
|
<div key={j} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/3" />
|
|
<div className="admin-skeleton-line w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const fullFieldOrder = getFullFieldOrder()
|
|
|
|
const renderBankButtonContent = (): React.ReactNode => {
|
|
if (bankSaving) {
|
|
return <><div className="admin-spinner admin-spinner-sm" />Ukládání...</>
|
|
}
|
|
if (editingBank !== null) return 'Uložit změny'
|
|
return (
|
|
<>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
Přidat účet
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<motion.div
|
|
className="admin-page-header"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
>
|
|
<div>
|
|
<h1 className="admin-page-title">Nastavení firmy</h1>
|
|
<p className="admin-page-subtitle">Firemní údaje, číslování dokladů a výchozí hodnoty</p>
|
|
</div>
|
|
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Ukládání...
|
|
</>
|
|
) : 'Uložit nastavení'}
|
|
</button>
|
|
</motion.div>
|
|
|
|
<div className="offers-settings-grid">
|
|
{/* Company Info */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.06 }}
|
|
>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Firemní údaje</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<div className="admin-form">
|
|
<FormField label="Název firmy">
|
|
<input type="text" value={form.company_name} onChange={(e) => updateField('company_name', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
<div className="admin-form-row">
|
|
<FormField label="Ulice">
|
|
<input type="text" value={form.street} onChange={(e) => updateField('street', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
<FormField label="Město">
|
|
<input type="text" value={form.city} onChange={(e) => updateField('city', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row">
|
|
<FormField label="PSČ">
|
|
<input type="text" value={form.postal_code} onChange={(e) => updateField('postal_code', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
<FormField label="Země">
|
|
<input type="text" value={form.country} onChange={(e) => updateField('country', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row">
|
|
<FormField label="IČO">
|
|
<input type="text" value={form.company_id} onChange={(e) => updateField('company_id', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
<FormField label="DIČ">
|
|
<input type="text" value={form.vat_id} onChange={(e) => updateField('vat_id', e.target.value)} className="admin-form-input" />
|
|
</FormField>
|
|
</div>
|
|
<div style={{ marginTop: 4 }}>
|
|
<label className="admin-form-label" style={{ display: 'block', marginBottom: 4 }}>Vlastní pole</label>
|
|
{customFields.map((field, idx) => (
|
|
<div key={field._key} style={{ marginBottom: 8 }}>
|
|
<div className="admin-form-row" style={{ marginBottom: 0, alignItems: 'flex-end' }}>
|
|
<FormField label={idx === 0 ? 'Název' : '\u00A0'} style={{ flex: 1 }}>
|
|
<input
|
|
type="text"
|
|
value={field.name}
|
|
onChange={(e) => {
|
|
const updated = [...customFields]
|
|
updated[idx] = { ...updated[idx], name: e.target.value }
|
|
setCustomFields(updated)
|
|
}}
|
|
className="admin-form-input"
|
|
placeholder="Např. Tel."
|
|
/>
|
|
</FormField>
|
|
<FormField label={idx === 0 ? 'Hodnota' : '\u00A0'} style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<input
|
|
type="text"
|
|
value={field.value}
|
|
onChange={(e) => {
|
|
const updated = [...customFields]
|
|
updated[idx] = { ...updated[idx], value: e.target.value }
|
|
setCustomFields(updated)
|
|
}}
|
|
className="admin-form-input"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const key = `custom_${idx}`
|
|
setFieldOrder(prev =>
|
|
prev
|
|
.filter(k => k !== key)
|
|
.map(k => {
|
|
if (k.startsWith('custom_')) {
|
|
const ki = parseInt(k.split('_')[1])
|
|
if (ki > idx) return `custom_${ki - 1}`
|
|
}
|
|
return k
|
|
})
|
|
)
|
|
setCustomFields(customFields.filter((_, i) => i !== idx))
|
|
}}
|
|
className="admin-btn-icon danger"
|
|
title="Odebrat pole"
|
|
aria-label="Odebrat pole"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</FormField>
|
|
</div>
|
|
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={field.showLabel !== false}
|
|
onChange={(e) => {
|
|
const updated = [...customFields]
|
|
updated[idx] = { ...updated[idx], showLabel: e.target.checked }
|
|
setCustomFields(updated)
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: '0.8rem' }}>Zobrazit název v PDF</span>
|
|
</label>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setCustomFields([...customFields, { name: '', value: '', showLabel: true, _key: `cf-${++customFieldKeyCounter.current}` }])}
|
|
className="admin-btn admin-btn-secondary"
|
|
style={{ marginTop: 4, fontSize: '0.85rem' }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
Přidat pole
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Bank Accounts */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.08 }}
|
|
>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Bankovní účty</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
{bankLoading ? (
|
|
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
|
{[0, 1, 2].map(i => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/3" />
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{bankAccounts.length > 0 && (
|
|
<div className="admin-table-responsive mb-4">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Název</th>
|
|
<th>Banka</th>
|
|
<th>Číslo účtu</th>
|
|
<th>IBAN</th>
|
|
<th>BIC/SWIFT</th>
|
|
<th>Měna</th>
|
|
<th style={{ width: 70 }}>Výchozí</th>
|
|
<th style={{ width: 80 }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{bankAccounts.map(acc => (
|
|
<tr key={acc.id} style={editingBank === acc.id ? { background: 'var(--bg-tertiary)' } : undefined}>
|
|
<td>{acc.account_name}</td>
|
|
<td>{acc.bank_name}</td>
|
|
<td className="admin-mono">{acc.account_number}</td>
|
|
<td className="admin-mono">{acc.iban}</td>
|
|
<td className="admin-mono">{acc.bic}</td>
|
|
<td>{acc.currency}</td>
|
|
<td className="text-center">
|
|
{acc.is_default ? <span className="text-accent fw-600">✓</span> : '\u2013'}
|
|
</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button type="button" onClick={() => startEditBank(acc)} className="admin-btn-icon" title="Upravit" aria-label="Upravit">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
<button type="button" onClick={() => handleBankDelete(acc.id)} className="admin-btn-icon danger" title="Smazat" aria-label="Smazat">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ background: 'var(--bg-tertiary)', borderRadius: 'var(--border-radius)', padding: 16 }}>
|
|
<h4 className="text-secondary" style={{ margin: '0 0 12px', fontSize: '0.9rem' }}>
|
|
{editingBank !== null ? 'Upravit účet' : 'Přidat nový účet'}
|
|
</h4>
|
|
<div className="admin-form">
|
|
<div className="admin-form-row">
|
|
<FormField label="Název účtu" required>
|
|
<input type="text" value={bankForm.account_name} onChange={e => setBankForm(f => ({ ...f, account_name: e.target.value }))} className="admin-form-input" placeholder="Např. Hlavní CZK účet" />
|
|
</FormField>
|
|
<FormField label="Název banky">
|
|
<input type="text" value={bankForm.bank_name} onChange={e => setBankForm(f => ({ ...f, bank_name: e.target.value }))} className="admin-form-input" placeholder="Např. MONETA Money Bank, a.s." />
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row">
|
|
<FormField label="Číslo účtu">
|
|
<input type="text" value={bankForm.account_number} onChange={e => setBankForm(f => ({ ...f, account_number: e.target.value }))} className="admin-form-input" placeholder="123456789/0600" />
|
|
</FormField>
|
|
<FormField label="Měna">
|
|
<select value={bankForm.currency} onChange={e => setBankForm(f => ({ ...f, currency: e.target.value }))} className="admin-form-select">
|
|
<option value="CZK">CZK</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="USD">USD</option>
|
|
<option value="GBP">GBP</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
<div className="admin-form-row">
|
|
<FormField label="IBAN">
|
|
<input type="text" value={bankForm.iban} onChange={e => setBankForm(f => ({ ...f, iban: e.target.value }))} className="admin-form-input" placeholder="CZ65 0800 0000 1920 0014 5399" />
|
|
</FormField>
|
|
<FormField label="BIC / SWIFT">
|
|
<input type="text" value={bankForm.bic} onChange={e => setBankForm(f => ({ ...f, bic: e.target.value }))} className="admin-form-input" placeholder="GIBACZPX" />
|
|
</FormField>
|
|
</div>
|
|
<label className="admin-form-checkbox">
|
|
<input type="checkbox" checked={bankForm.is_default} onChange={e => setBankForm(f => ({ ...f, is_default: e.target.checked }))} />
|
|
<span>Výchozí účet (použije se automaticky při vytváření faktury)</span>
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<button type="button" onClick={handleBankSave} className="admin-btn admin-btn-primary" disabled={bankSaving} style={{ fontSize: '0.85rem' }}>
|
|
{renderBankButtonContent()}
|
|
</button>
|
|
{editingBank !== null && (
|
|
<button type="button" onClick={resetBankForm} className="admin-btn admin-btn-secondary" style={{ fontSize: '0.85rem' }}>
|
|
Zrušit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* PDF Field Order */}
|
|
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.08 }}>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Pořadí polí dodavatele v PDF</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<small className="admin-form-hint" style={{ display: 'block', marginBottom: 12 }}>
|
|
Určuje pořadí řádků v adresním bloku dodavatele na PDF nabídce.
|
|
</small>
|
|
<div className="admin-reorder-list">
|
|
{fullFieldOrder.map((key, index) => (
|
|
<div key={key} className="admin-reorder-item">
|
|
<div className="admin-reorder-arrows">
|
|
<button type="button" onClick={() => moveField(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
|
</button>
|
|
<button type="button" onClick={() => moveField(index, 1)} disabled={index === fullFieldOrder.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
|
</button>
|
|
</div>
|
|
<span className={`admin-reorder-label${key.startsWith('custom_') ? ' accent' : ''}`}>
|
|
{getFieldDisplayName(key)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Logo */}
|
|
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.12 }}>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Logo</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<div className="offers-logo-section">
|
|
{logoUrl && (
|
|
<div className="offers-logo-preview">
|
|
<img src={logoUrl} alt="Logo" />
|
|
</div>
|
|
)}
|
|
<label className="admin-btn admin-btn-secondary" style={{ cursor: 'pointer' }}>
|
|
{uploadingLogo ? (
|
|
<><div className="admin-spinner admin-spinner-sm" />Nahrávání...</>
|
|
) : (
|
|
<>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
Nahrát logo
|
|
</>
|
|
)}
|
|
<input type="file" accept="image/*" onChange={handleLogoUpload} style={{ display: 'none' }} disabled={uploadingLogo} />
|
|
</label>
|
|
<small className="admin-form-hint">PNG, JPEG, GIF nebo WebP, max 5 MB</small>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Cislovani dokladu */}
|
|
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Číslování dokladů</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<div className="admin-form">
|
|
<FormField label="Nabídky — prefix">
|
|
<input type="text" value={form.quotation_prefix} onChange={(e) => updateField('quotation_prefix', e.target.value)} className="admin-form-input" placeholder="N" style={{ maxWidth: 120 }} />
|
|
<small className="admin-form-hint">
|
|
Formát: ROK/PREFIX/ČÍSLO — ukázka: {new Date().getFullYear()}/{form.quotation_prefix || 'N'}/001
|
|
</small>
|
|
</FormField>
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
|
|
<FormField label="Objednávky a projekty — typový kód">
|
|
<input type="text" value={form.order_type_code} onChange={(e) => updateField('order_type_code', e.target.value)} className="admin-form-input" placeholder="71" style={{ maxWidth: 120 }} />
|
|
<small className="admin-form-hint">
|
|
Formát: RRKÓD#### — ukázka: {currentYear}{form.order_type_code || '71'}0001
|
|
</small>
|
|
</FormField>
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: '0.75rem 0' }} />
|
|
<FormField label="Faktury — typový kód">
|
|
<input type="text" value={form.invoice_type_code} onChange={(e) => updateField('invoice_type_code', e.target.value)} className="admin-form-input" placeholder="81" style={{ maxWidth: 120 }} />
|
|
<small className="admin-form-hint">
|
|
Formát: RRKÓD#### — ukázka: {currentYear}{form.invoice_type_code || '81'}0001
|
|
</small>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Default values */}
|
|
<motion.div className="admin-card" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.15 }}>
|
|
<div className="admin-card-header">
|
|
<h3 className="admin-card-title">Výchozí hodnoty</h3>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<div className="admin-form">
|
|
<div className="admin-form-row">
|
|
<FormField label="Výchozí měna">
|
|
<select value={form.default_currency} onChange={(e) => updateField('default_currency', e.target.value)} className="admin-form-select">
|
|
<option value="EUR">EUR</option>
|
|
<option value="USD">USD</option>
|
|
<option value="CZK">CZK</option>
|
|
<option value="GBP">GBP</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Výchozí sazba DPH (%)">
|
|
<input type="number" value={form.default_vat_rate} onChange={(e) => updateField('default_vat_rate', parseFloat(e.target.value) || 0)} className="admin-form-input" step="0.1" />
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|