|
@@ -1,7 +1,8 @@
|
|
|
'use client'
|
|
'use client'
|
|
|
|
|
|
|
|
-import type { ReactNode } from 'react'
|
|
|
|
|
|
|
+import { useRef, useState, type ReactNode } from 'react'
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useTranslations } from 'next-intl'
|
|
|
|
|
+import { toast } from 'sonner'
|
|
|
import type {
|
|
import type {
|
|
|
InvoiceDocumentStyle,
|
|
InvoiceDocumentStyle,
|
|
|
InvoiceLayoutConfig,
|
|
InvoiceLayoutConfig,
|
|
@@ -125,6 +126,106 @@ function Color({
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Give this document a mark of its own, without leaving the designer.
|
|
|
|
|
+ *
|
|
|
|
|
+ * A workshop's paperwork does not always want the badge the app wears: a
|
|
|
|
|
+ * wider version for a letterhead, or one with the address set into it. So
|
|
|
|
|
+ * what is uploaded here belongs to this document, and the company logo is
|
|
|
|
|
+ * what prints until something is. Changing it here never touches the picture
|
|
|
|
|
+ * in the sidebar, which is the surprise this exists to avoid.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Put where the logo is being looked at, because somebody adjusting the
|
|
|
|
|
+ * letterhead can see the file is wrong precisely because it is in front of
|
|
|
|
|
+ * them, and sending them to another screen loses the layout in progress.
|
|
|
|
|
+ */
|
|
|
|
|
+function LogoUpload({
|
|
|
|
|
+ value,
|
|
|
|
|
+ own,
|
|
|
|
|
+ onChange,
|
|
|
|
|
+}: {
|
|
|
|
|
+ value: string
|
|
|
|
|
+ own: boolean
|
|
|
|
|
+ onChange: (url: string) => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ const t = useTranslations('settings.designer')
|
|
|
|
|
+ const input = useRef<HTMLInputElement>(null)
|
|
|
|
|
+ const [busy, setBusy] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const upload = async (file: File) => {
|
|
|
|
|
+ setBusy(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body = new FormData()
|
|
|
|
|
+ body.append('file', file)
|
|
|
|
|
+ const res = await fetch('/api/protected/upload/logo', { method: 'POST', body })
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const error = await res.json().catch(() => ({}))
|
|
|
|
|
+ toast.error(error.error || t('logoFailed'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const { url } = await res.json()
|
|
|
|
|
+ onChange(url)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t('logoFailed'))
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setBusy(false)
|
|
|
|
|
+ // Cleared so choosing the same file again still fires a change.
|
|
|
|
|
+ if (input.current) input.current.value = ''
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <div className="flex items-center gap-2.5">
|
|
|
|
|
+ <div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border border-[#e3e5e9] bg-white">
|
|
|
|
|
+ {value ? (
|
|
|
|
|
+ // Not next/image: an uploaded URL the loader cannot size.
|
|
|
|
|
+ <img src={value} alt="" className="max-h-full max-w-full object-contain" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-[15px] text-[#c3c7cd]">◫</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex min-w-0 flex-1 gap-1.5">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ disabled={busy}
|
|
|
|
|
+ onClick={() => input.current?.click()}
|
|
|
|
|
+ className="flex-1 rounded-[6px] border border-[#d7dade] bg-white px-2 py-1.5 text-[12.5px] font-medium hover:bg-[#f6f7f8] disabled:opacity-60"
|
|
|
|
|
+ >
|
|
|
|
|
+ {busy ? t('logoUploading') : value ? t('logoReplace') : t('logoUpload')}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {/* Clearing this document's own mark returns it to the company
|
|
|
|
|
+ logo; it never deletes the company logo itself, which belongs
|
|
|
|
|
+ to the app rather than to any sheet. */}
|
|
|
|
|
+ {own && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ disabled={busy}
|
|
|
|
|
+ onClick={() => onChange('')}
|
|
|
|
|
+ className="rounded-[6px] border border-[#d7dade] bg-white px-2 py-1.5 text-[12.5px] text-[#8a8f97] hover:text-[#dc2626] disabled:opacity-60"
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('logoReset')}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-[11.5px] leading-snug text-[#8a8f97]">
|
|
|
|
|
+ {own ? t('logoOwn') : t('logoInherited')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <input
|
|
|
|
|
+ ref={input}
|
|
|
|
|
+ type="file"
|
|
|
|
|
+ accept="image/*"
|
|
|
|
|
+ className="hidden"
|
|
|
|
|
+ onChange={(event) => {
|
|
|
|
|
+ const file = event.target.files?.[0]
|
|
|
|
|
+ if (file) void upload(file)
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function Slider({
|
|
function Slider({
|
|
|
label,
|
|
label,
|
|
|
value,
|
|
value,
|
|
@@ -211,6 +312,9 @@ export function DesignerInspector({
|
|
|
onSectionStyle,
|
|
onSectionStyle,
|
|
|
onDocument,
|
|
onDocument,
|
|
|
onTemplate,
|
|
onTemplate,
|
|
|
|
|
+ logoUrl,
|
|
|
|
|
+ ownLogo,
|
|
|
|
|
+ onLogo,
|
|
|
}: {
|
|
}: {
|
|
|
layout: InvoiceLayoutConfig
|
|
layout: InvoiceLayoutConfig
|
|
|
template: DesignerTemplate
|
|
template: DesignerTemplate
|
|
@@ -223,6 +327,11 @@ export function DesignerInspector({
|
|
|
onSectionStyle: (id: string, style: InvoiceSectionStyle | undefined) => void
|
|
onSectionStyle: (id: string, style: InvoiceSectionStyle | undefined) => void
|
|
|
onDocument: (patch: InvoiceDocumentStyle) => void
|
|
onDocument: (patch: InvoiceDocumentStyle) => void
|
|
|
onTemplate: (patch: Partial<DesignerTemplate>) => void
|
|
onTemplate: (patch: Partial<DesignerTemplate>) => void
|
|
|
|
|
+ /** The picture this document will print, whichever it comes from. */
|
|
|
|
|
+ logoUrl: string
|
|
|
|
|
+ /** Whether that picture is this document's own rather than the company's. */
|
|
|
|
|
+ ownLogo: boolean
|
|
|
|
|
+ onLogo: (url: string) => void
|
|
|
}) {
|
|
}) {
|
|
|
const t = useTranslations('settings.designer')
|
|
const t = useTranslations('settings.designer')
|
|
|
const tSection = useTranslations('settings.layoutEditor.sections')
|
|
const tSection = useTranslations('settings.layoutEditor.sections')
|
|
@@ -252,12 +361,19 @@ export function DesignerInspector({
|
|
|
* The fields this section shows, resolved the way the generator resolves
|
|
* The fields this section shows, resolved the way the generator resolves
|
|
|
* them: no list of its own means every built-in field, visible.
|
|
* them: no list of its own means every built-in field, visible.
|
|
|
*/
|
|
*/
|
|
|
- const builtinIds = new Set(getBuiltinFieldsForSection(section.id).map((f) => f.id))
|
|
|
|
|
- const resolvedFields = (
|
|
|
|
|
- section.fields ??
|
|
|
|
|
- getBuiltinFieldsForSection(section.id).map((f) => ({ id: f.id, visible: true }))
|
|
|
|
|
- ) // A stored id no builtin list carries any more is a leftover, not a field.
|
|
|
|
|
|
|
+ const builtins = getBuiltinFieldsForSection(section.id)
|
|
|
|
|
+ const builtinIds = new Set(builtins.map((f) => f.id))
|
|
|
|
|
+ const stored = (section.fields ?? builtins.map((f) => ({ id: f.id, visible: true }))) // A stored id no builtin list carries any more is a leftover, not a field.
|
|
|
.filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
|
|
.filter((f) => isCustomFieldId(f.id) || builtinIds.has(f.id))
|
|
|
|
|
+ // A field added after this layout was written still needs its switch.
|
|
|
|
|
+ // Without this a saved design, which is loaded as it was stored, can never
|
|
|
|
|
+ // reach anything built since, and the option looks simply missing.
|
|
|
|
|
+ const resolvedFields = [
|
|
|
|
|
+ ...stored,
|
|
|
|
|
+ ...builtins
|
|
|
|
|
+ .filter((f) => !stored.some((existing) => existing.id === f.id))
|
|
|
|
|
+ .map((f) => ({ id: f.id, visible: false })),
|
|
|
|
|
+ ]
|
|
|
const setFields = (fields: { id: string; visible: boolean }[]) =>
|
|
const setFields = (fields: { id: string; visible: boolean }[]) =>
|
|
|
onSection(section.id, { fields })
|
|
onSection(section.id, { fields })
|
|
|
const setStyle = (patch: InvoiceSectionStyle) => {
|
|
const setStyle = (patch: InvoiceSectionStyle) => {
|
|
@@ -388,6 +504,7 @@ export function DesignerInspector({
|
|
|
|
|
|
|
|
{section.id === 'header' && (
|
|
{section.id === 'header' && (
|
|
|
<Group title={t('logo')}>
|
|
<Group title={t('logo')}>
|
|
|
|
|
+ <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />
|
|
|
{/* The logo is printed by the header, so its size is set where
|
|
{/* The logo is printed by the header, so its size is set where
|
|
|
the header is rather than in a list of sheet properties. */}
|
|
the header is rather than in a list of sheet properties. */}
|
|
|
<Slider
|
|
<Slider
|
|
@@ -402,6 +519,16 @@ export function DesignerInspector({
|
|
|
</Group>
|
|
</Group>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
|
|
+ {/* The footer prints the same logo when it is switched on below, so
|
|
|
|
|
+ it offers the same swap rather than sending somebody to the
|
|
|
|
|
+ header to change a picture they are looking at down here. */}
|
|
|
|
|
+ {section.id === 'footer' && (
|
|
|
|
|
+ <Group title={t('logo')}>
|
|
|
|
|
+ <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />
|
|
|
|
|
+ <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('footerLogoHint')}</p>
|
|
|
|
|
+ </Group>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{SECTIONS_WITH_FIELDS.has(section.id) && (
|
|
{SECTIONS_WITH_FIELDS.has(section.id) && (
|
|
|
<Group title={t('fields')}>
|
|
<Group title={t('fields')}>
|
|
|
{resolvedFields.map((field) => (
|
|
{resolvedFields.map((field) => (
|