Add persistent invoice template management

This commit is contained in:
2026-03-13 16:02:46 +01:00
parent ff237332e1
commit 490be6a89b
16 changed files with 1254 additions and 146 deletions

View File

@@ -0,0 +1,16 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("invoiceTemplates")
public record InvoiceTemplate(
@Id String userId,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,21 @@
package de.svencarstensen.muh.domain;
public record InvoiceTemplateElement(
String id,
String paletteId,
String kind,
String label,
String content,
Integer x,
Integer y,
Integer width,
Integer height,
Integer fontSize,
Integer fontWeight,
String textAlign,
String lineOrientation,
String imageSrc,
Integer imageNaturalWidth,
Integer imageNaturalHeight
) {
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.InvoiceTemplate;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface InvoiceTemplateRepository extends MongoRepository<InvoiceTemplate, String> {
}

View File

@@ -4,9 +4,12 @@ import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.UserRole; import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository; import de.svencarstensen.muh.repository.AppUserRepository;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Objects;
@Service @Service
public class AuthorizationService { public class AuthorizationService {
@@ -43,10 +46,11 @@ public class AuthorizationService {
return user.accountId().trim(); return user.accountId().trim();
} }
private String requireText(String value, String message) { private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
} }
return value.trim(); String sanitized = Objects.requireNonNull(value).trim();
return Objects.requireNonNull(sanitized);
} }
} }

View File

@@ -7,6 +7,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -15,6 +16,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Objects;
@Component @Component
public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
@@ -28,7 +30,11 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
} }
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
)
throws ServletException, IOException { throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) { if (authorization == null || !authorization.startsWith("Bearer ")) {
@@ -38,7 +44,7 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
try { try {
AuthenticatedUser tokenUser = authTokenService.parseToken(authorization.substring(7)); AuthenticatedUser tokenUser = authTokenService.parseToken(authorization.substring(7));
AppUser user = appUserRepository.findById(tokenUser.id()) AppUser user = appUserRepository.findById(Objects.requireNonNull(tokenUser.id()))
.filter(AppUser::active) .filter(AppUser::active)
.orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig")); .orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig"));

View File

@@ -485,7 +485,7 @@ public class CatalogService {
if (isPrimaryUser(existing) && actor.role() != UserRole.ADMIN) { if (isPrimaryUser(existing) && actor.role() != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann nicht geloescht werden"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann nicht geloescht werden");
} }
appUserRepository.deleteById(existing.id()); appUserRepository.deleteById(requireText(existing.id(), "Benutzer-ID fehlt"));
} }
public void changePassword(String actorId, String id, String newPassword) { public void changePassword(String actorId, String id, String newPassword) {

View File

@@ -0,0 +1,181 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplate;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.InvoiceTemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Service
public class InvoiceTemplateService {
private final AppUserRepository appUserRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
public InvoiceTemplateService(
AppUserRepository appUserRepository,
InvoiceTemplateRepository invoiceTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository;
}
public InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return invoiceTemplateRepository.findById(userId)
.map(this::toResponse)
.orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateResponse saveTemplate(String actorId, List<InvoiceTemplateElementPayload> payloadElements) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
InvoiceTemplate existing = invoiceTemplateRepository.findById(userId).orElse(null);
LocalDateTime now = LocalDateTime.now();
InvoiceTemplate saved = invoiceTemplateRepository.save(new InvoiceTemplate(
userId,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : now,
now
));
return toResponse(saved);
}
private AppUser requireActiveUser(@NonNull String actorId) {
return appUserRepository.findById(actorId)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"));
}
private @NonNull String requireActorId(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
String sanitized = Objects.requireNonNull(actorId).trim();
return Objects.requireNonNull(sanitized);
}
private List<InvoiceTemplateElement> sanitizeElements(List<InvoiceTemplateElementPayload> payloadElements) {
if (payloadElements == null || payloadElements.isEmpty()) {
return List.of();
}
return payloadElements.stream()
.map(this::sanitizeElement)
.filter(Objects::nonNull)
.toList();
}
private InvoiceTemplateElement sanitizeElement(InvoiceTemplateElementPayload payload) {
if (payload == null
|| isBlank(payload.id())
|| isBlank(payload.paletteId())
|| isBlank(payload.kind())
|| payload.x() == null
|| payload.y() == null
|| payload.width() == null
|| payload.fontSize() == null) {
return null;
}
return new InvoiceTemplateElement(
payload.id().trim(),
payload.paletteId().trim(),
payload.kind().trim(),
trimOrEmpty(payload.label()),
nullToEmpty(payload.content()),
payload.x(),
payload.y(),
payload.width(),
payload.height(),
payload.fontSize(),
payload.fontWeight(),
blankToNull(payload.textAlign()),
blankToNull(payload.lineOrientation()),
blankToNull(payload.imageSrc()),
payload.imageNaturalWidth(),
payload.imageNaturalHeight()
);
}
private InvoiceTemplateResponse toResponse(InvoiceTemplate template) {
List<InvoiceTemplateElementPayload> elements = template.elements() == null
? List.of()
: template.elements().stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, elements, template.updatedAt());
}
private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
return new InvoiceTemplateElementPayload(
element.id(),
element.paletteId(),
element.kind(),
element.label(),
element.content(),
element.x(),
element.y(),
element.width(),
element.height(),
element.fontSize(),
element.fontWeight(),
element.textAlign(),
element.lineOrientation(),
element.imageSrc(),
element.imageNaturalWidth(),
element.imageNaturalHeight()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private String trimOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
public record InvoiceTemplateElementPayload(
String id,
String paletteId,
String kind,
String label,
String content,
Integer x,
Integer y,
Integer width,
Integer height,
Integer fontSize,
Integer fontWeight,
String textAlign,
String lineOrientation,
String imageSrc,
Integer imageNaturalWidth,
Integer imageNaturalHeight
) {
}
public record InvoiceTemplateResponse(
boolean stored,
List<InvoiceTemplateElementPayload> elements,
LocalDateTime updatedAt
) {
}
}

View File

@@ -102,7 +102,7 @@ public class ReportService {
helper.setFrom(requireText(mailFrom, "Absender fehlt")); helper.setFrom(requireText(mailFrom, "Absender fehlt"));
helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt")); helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt"));
helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber()); helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber());
helper.setText(buildMailBody(sample, customerSignature), false); helper.setText(Objects.requireNonNull(buildMailBody(sample, customerSignature)), false);
helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf))); helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf)));
sender.send(message); sender.send(message);
} catch (Exception exception) { } catch (Exception exception) {
@@ -171,7 +171,8 @@ public class ReportService {
return defaultCustomerSignature(); return defaultCustomerSignature();
} }
AppUser actor = appUserRepository.findById(actorId.trim()).orElse(null); String normalizedActorId = Objects.requireNonNull(actorId).trim();
AppUser actor = appUserRepository.findById(Objects.requireNonNull(normalizedActorId)).orElse(null);
if (actor == null) { if (actor == null) {
return defaultCustomerSignature(); return defaultCustomerSignature();
} }

View File

@@ -28,8 +28,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service

View File

@@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;

View File

@@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@@ -1,24 +1,33 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.service.InvoiceTemplateService;
import de.svencarstensen.muh.security.SecuritySupport; import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/session") @RequestMapping("/api/session")
public class SessionController { public class SessionController {
private final CatalogService catalogService; private final CatalogService catalogService;
private final InvoiceTemplateService invoiceTemplateService;
private final SecuritySupport securitySupport; private final SecuritySupport securitySupport;
public SessionController(CatalogService catalogService, SecuritySupport securitySupport) { public SessionController(
CatalogService catalogService,
InvoiceTemplateService invoiceTemplateService,
SecuritySupport securitySupport
) {
this.catalogService = catalogService; this.catalogService = catalogService;
this.invoiceTemplateService = invoiceTemplateService;
this.securitySupport = securitySupport; this.securitySupport = securitySupport;
} }
@@ -27,6 +36,11 @@ public class SessionController {
return catalogService.currentUser(securitySupport.currentUser().id()); return catalogService.currentUser(securitySupport.currentUser().id());
} }
@GetMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentInvoiceTemplate() {
return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@PostMapping("/password-login") @PostMapping("/password-login")
public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) { public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.email(), request.password()); return catalogService.loginWithPassword(request.email(), request.password());
@@ -46,6 +60,16 @@ public class SessionController {
)); ));
} }
@PutMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate(
@RequestBody InvoiceTemplateRequest request
) {
return invoiceTemplateService.saveTemplate(
securitySupport.currentUser().id(),
request.elements()
);
}
public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) { public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
} }
@@ -60,4 +84,9 @@ public class SessionController {
@NotBlank String password @NotBlank String password
) { ) {
} }
public record InvoiceTemplateRequest(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> elements
) {
}
} }

View File

@@ -1,3 +1,2 @@
export const USER_STORAGE_KEY = "muh.current-user"; export const USER_STORAGE_KEY = "muh.current-user";
export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token"; export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token";
export const INVOICE_TEMPLATE_STORAGE_KEY = "muh.invoice-template";

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ export default function LoginPage() {
const [showRegistration, setShowRegistration] = useState(false); const [showRegistration, setShowRegistration] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loginInputsUnlocked, setLoginInputsUnlocked] = useState(false);
const [showLoginValidation, setShowLoginValidation] = useState(false); const [showLoginValidation, setShowLoginValidation] = useState(false);
const [showRegisterValidation, setShowRegisterValidation] = useState(false); const [showRegisterValidation, setShowRegisterValidation] = useState(false);
const [registration, setRegistration] = useState({ const [registration, setRegistration] = useState({
@@ -28,6 +29,10 @@ export default function LoginPage() {
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setSession } = useSession(); const { setSession } = useSession();
function unlockLoginInputs() {
setLoginInputsUnlocked(true);
}
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) { async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowLoginValidation(true); setShowLoginValidation(true);
@@ -116,14 +121,23 @@ export default function LoginPage() {
<div className="auth-grid"> <div className="auth-grid">
{!showRegistration ? ( {!showRegistration ? (
<form className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`} onSubmit={handlePasswordLogin}> <form
className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`}
onSubmit={handlePasswordLogin}
autoComplete="off"
>
<label className="field field--required"> <label className="field field--required">
<span>E-Mail</span> <span>E-Mail</span>
<input <input
type="email" type="email"
name="login-email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
onFocus={unlockLoginInputs}
onPointerDown={unlockLoginInputs}
placeholder="z. B. name@hof.de" placeholder="z. B. name@hof.de"
autoComplete="off"
readOnly={!loginInputsUnlocked}
required required
/> />
</label> </label>
@@ -131,8 +145,13 @@ export default function LoginPage() {
<span>Passwort</span> <span>Passwort</span>
<input <input
type="password" type="password"
name="login-password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
onFocus={unlockLoginInputs}
onPointerDown={unlockLoginInputs}
autoComplete="new-password"
readOnly={!loginInputsUnlocked}
required required
/> />
</label> </label>

View File

@@ -751,6 +751,9 @@ a {
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
height: 100%; height: 100%;
padding: 20px; padding: 20px;
background: var(--surface-strong);
box-shadow: none;
backdrop-filter: none;
overflow: hidden; overflow: hidden;
} }
@@ -854,13 +857,14 @@ a {
} }
.invoice-template__canvas-shell { .invoice-template__canvas-shell {
position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding: 0 0 8px; padding: 0 0 10px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -883,7 +887,6 @@ a {
linear-gradient(90deg, transparent 31px, rgba(37, 49, 58, 0.05) 32px), linear-gradient(90deg, transparent 31px, rgba(37, 49, 58, 0.05) 32px),
#fffdf9; #fffdf9;
background-size: auto, 32px 32px, 32px 32px, auto; background-size: auto, 32px 32px, 32px 32px, auto;
box-shadow: 0 32px 60px rgba(54, 44, 27, 0.12);
overflow: hidden; overflow: hidden;
transform-origin: top left; transform-origin: top left;
will-change: transform; will-change: transform;
@@ -891,7 +894,7 @@ a {
.invoice-template__canvas.is-active { .invoice-template__canvas.is-active {
border-color: rgba(17, 109, 99, 0.48); border-color: rgba(17, 109, 99, 0.48);
box-shadow: 0 32px 60px rgba(54, 44, 27, 0.12), 0 0 0 6px rgba(17, 109, 99, 0.08); box-shadow: 0 0 0 6px rgba(17, 109, 99, 0.08);
} }
.invoice-template__canvas::before { .invoice-template__canvas::before {
@@ -924,9 +927,15 @@ a {
color: var(--text); color: var(--text);
cursor: move; cursor: move;
box-shadow: none; box-shadow: none;
overflow: visible;
user-select: none; user-select: none;
} }
.invoice-template__canvas-element--line,
.invoice-template__canvas-element--image {
padding: 0;
}
.invoice-template__canvas-element:hover, .invoice-template__canvas-element:hover,
.invoice-template__canvas-element.is-selected { .invoice-template__canvas-element.is-selected {
border-color: rgba(17, 109, 99, 0.28); border-color: rgba(17, 109, 99, 0.28);
@@ -938,6 +947,11 @@ a {
z-index: 2; z-index: 2;
} }
.invoice-template__canvas-element.is-resizing {
cursor: nwse-resize;
z-index: 3;
}
.invoice-template__canvas-element-text { .invoice-template__canvas-element-text {
display: block; display: block;
line-height: 1.35; line-height: 1.35;
@@ -945,6 +959,56 @@ a {
word-break: break-word; word-break: break-word;
} }
.invoice-template__canvas-line {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;
}
.invoice-template__canvas-image {
display: block;
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: contain;
pointer-events: none;
}
.invoice-template__canvas-image-placeholder {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: 100%;
padding: 12px;
border: 1px dashed rgba(17, 109, 99, 0.28);
border-radius: 18px;
background: rgba(17, 109, 99, 0.05);
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
pointer-events: none;
text-align: center;
}
.invoice-template__canvas-element-resize-handle {
position: absolute;
right: -7px;
bottom: -7px;
width: 14px;
height: 14px;
border: 1px solid rgba(17, 109, 99, 0.48);
border-radius: 999px;
background: var(--surface-strong);
box-shadow: 0 0 0 3px rgba(17, 109, 99, 0.12);
cursor: nwse-resize;
pointer-events: auto;
touch-action: none;
}
.invoice-template__inspector { .invoice-template__inspector {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -957,7 +1021,9 @@ a {
.invoice-template__inspector-actions { .invoice-template__inspector-actions {
display: flex; display: flex;
gap: 12px;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
} }
.invoice-template-page .field-grid { .invoice-template-page .field-grid {