user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import React from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
interface CollapsibleSectionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
isExpanded,
onToggle,
children,
}) => (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<button
onClick={onToggle}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors rounded-t-lg focus:outline-none">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-500 dark:text-gray-400" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500 dark:text-gray-400" />
)}
</button>
{isExpanded && (
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">{children}</div>
)}
</div>
);
export default CollapsibleSection;

View File

@@ -0,0 +1,520 @@
import React from "react";
import { sanitizeHtml } from "@/shared/lib/sanitize";
import {
TemplateSampleData,
TemplateSampleSetting,
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { normalizeLetterLabelsValue } from "./utils/letterLabelsConstants";
import { normalizePageMarginValue } from "./components/PageMarginEditor";
interface TemplateLivePreviewProps {
formData: TemplateSampleData;
settings: TemplateSampleSetting[];
selectedHeader: string; // HTML string with <img>
selectedFooter: string; // HTML string with <img>
templateName?: string;
}
export const TemplateLivePreview: React.FC<TemplateLivePreviewProps> = ({
formData,
settings,
selectedHeader,
selectedFooter,
templateName,
}) => {
const { content, preferredLanguage, recordType } = formData;
// Debug: Log when component renders and show render count
const renderCount = React.useRef(0);
renderCount.current += 1;
// CSS parser that correctly handles data URIs (colons/semicolons inside url(...))
const parseCSSString = (cssString: string): React.CSSProperties => {
if (!cssString) return {};
const styles: React.CSSProperties = {};
// Walk char-by-char, splitting on ';' only when outside parentheses
let depth = 0;
let current = "";
const processDeclaration = (decl: string) => {
decl = decl.trim();
if (!decl) return;
// Split on first ':' only
const colonIdx = decl.indexOf(":");
if (colonIdx <= 0) return;
const property = decl.slice(0, colonIdx).trim();
const value = decl.slice(colonIdx + 1).trim();
if (!property || !value) return;
// Convert CSS property names to camelCase for React
const camelCaseProperty = property
.split("-")
.map((part, index) =>
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
)
.join("");
(styles as any)[camelCaseProperty] = value;
};
for (let i = 0; i < cssString.length; i++) {
const ch = cssString[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === ";" && depth === 0) {
processDeclaration(current);
current = "";
} else {
current += ch;
}
}
processDeclaration(current); // last declaration (no trailing semicolon)
return styles;
};
// Helper to extract style object from settings by code
const getStyleFor = (code: string): React.CSSProperties => {
const setting = settings.find((s) => s.code.includes(code));
if (!setting) return {};
const { value } = setting;
// If value is a string (CSS), parse it
if (typeof value === "string") {
return parseCSSString(value);
}
// Fallback for object format
return {
fontSize: (value as any)["font-size"],
fontWeight: (value as any)["font-weight"] as any,
textDecoration: (value as any)["text-decoration"] as any,
};
};
const getStyleForAliases = (aliases: string[]): React.CSSProperties => {
const normalized = aliases.map((alias) => alias.toLowerCase());
const setting = settings.find((s) => {
const code = s.code.toLowerCase();
return normalized.some((alias) => code.includes(alias));
});
if (!setting) return {};
const { value } = setting;
if (typeof value === "string") {
return parseCSSString(value);
}
return parseCSSString(
Object.entries(value as Record<string, string>)
.map(([key, val]) => `${key}: ${val};`)
.join(" "),
);
};
const subjectStyle = getStyleFor("subject");
const bodyStyle = getStyleFor("body");
const headerStyle = getStyleFor("header");
const footerStyle = getStyleFor("footer");
const receiverStyle = getStyleForAliases([
"receiver",
"recipient",
"letter-receiver",
"to",
]);
const fromStyle = getStyleForAliases([
"collaborator",
"from",
"letter-from",
"sender",
]);
const prefixStyle = getStyleForAliases([
"letter-prefix-style",
"prefix-style",
"letter-prefix",
"prefix",
]);
const suffixStyle = getStyleForAliases([
"letter-suffix-style",
"suffix-style",
"letter-suffix",
"suffix",
]);
const ccInternalStyle = getStyleForAliases([
"cc-internal",
"ccinternal",
"internal-cc",
"internalcc",
"cc-receiver",
]);
const ccExternalStyle = getStyleForAliases([
"cc-external",
"ccexternal",
"external-cc",
"externalcc",
"cc-receiver",
]);
const ccPrefixStyle = getStyleForAliases(["cc-prefix"]);
const ccSuffixStyle = getStyleForAliases(["cc-suffix"]);
// Customizable bilingual labels & page margins
const labels = normalizeLetterLabelsValue(
settings.find((s) => s.code === "letter-labels-text")?.value,
);
const margins = normalizePageMarginValue(
settings.find((s) => s.code === "letter-page-margin")?.value,
);
const displayLang: "en" | "am" = preferredLanguage === "am" ? "am" : "en";
const labelStyle = (
style?: Record<string, string>,
): React.CSSProperties =>
style
? parseCSSString(
Object.entries(style)
.map(([k, v]) => `${k}: ${v};`)
.join(" "),
)
: {};
const renderBodyContent = () => {
const bodyText = content.body || "";
const lines = bodyText
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const bulletItems = lines
.filter((line) => /^[-*•]\s+/.test(line))
.map((line) => line.replace(/^[-*•]\s+/, ""));
// Split list-specific style from text style so bullets can render on real <ul>/<li>.
const { listStyle, listStyleType, listStyleImage, ...textStyle } =
bodyStyle as Record<string, unknown>;
const resolvedListStyle =
(listStyle as string) || (listStyleType as string) || "disc";
const resolvedListStyleImage =
typeof listStyleImage === "string" ? listStyleImage : undefined;
if (bulletItems.length > 0) {
const isDisclosureTriangle =
resolvedListStyle === "disclosure-closed" ||
resolvedListStyle === "disclosure-open";
if (
resolvedListStyleImage &&
!isDisclosureTriangle
) {
return (
<div style={textStyle as React.CSSProperties}>
<ul
style={{
listStyle: "none",
listStyleImage: resolvedListStyleImage,
listStylePosition: "outside",
paddingLeft: "1.5rem",
}}
>
{bulletItems.map((item, idx) => (
<li key={`${item}-${idx}`}>{item}</li>
))}
</ul>
</div>
);
}
if (isDisclosureTriangle) {
// Use inline styles with triangle for disclosure
// Using Unicode escape sequence \25B6 for better backend compatibility
return (
<div style={textStyle}>
<ul style={{ listStyle: "none", paddingLeft: 0 }}>
{bulletItems.map((item, idx) => (
<li
key={`${item}-${idx}`}
style={{
position: "relative",
paddingLeft: "1.2rem",
}}
>
<span style={{ position: "absolute", left: 0 }}>
{String.fromCharCode(0x25b6)}
</span>
{item}
</li>
))}
</ul>
</div>
);
}
// Regular list-style rendering
return (
<div style={textStyle}>
<ul
style={{ listStyleType: resolvedListStyle, paddingLeft: "1.5rem" }}
>
{bulletItems.map((item, idx) => (
<li key={`${item}-${idx}`}>{item}</li>
))}
</ul>
</div>
);
}
return (
<div style={textStyle}>
{bodyText || "(Letter body will appear here)"}
</div>
);
};
// Helper to display a list of names based on preferred language
const toList = content.to?.filter(Boolean) || [];
const fromList = content.from?.filter(Boolean) || [];
const internalCcList = content.internalCC?.filter(Boolean) || [];
const externalCcList = content.externalCC?.filter(Boolean) || [];
const renderStyledList = (
items: string[],
style: React.CSSProperties,
emptyText: string,
) => {
const { listStyle, listStyleType, listStylePosition, listStyleImage, ...textStyle } =
style as Record<string, unknown>;
const specialListStyles = ["disclosure-closed", "disclosure-open"];
const resolvedListStyle =
typeof listStyle === "string" && specialListStyles.includes(listStyle)
? listStyle
: (listStyleType as string) || (listStyle as string) || "disc";
const resolvedListPosition = ((listStylePosition as string) ||
"outside") as React.CSSProperties["listStylePosition"];
const resolvedListStyleImage =
typeof listStyleImage === "string" ? listStyleImage : undefined;
if (items.length === 0) {
return <span>{emptyText}</span>;
}
if (
resolvedListStyleImage &&
resolvedListStyle !== "disclosure-closed" &&
resolvedListStyle !== "disclosure-open"
) {
return (
<ul
style={{
listStyle: "none",
listStyleImage: resolvedListStyleImage,
listStylePosition: resolvedListPosition,
paddingLeft: "1.5rem",
margin: 0,
}}
>
{items.map((item, idx) => (
<li key={`${item}-${idx}`} style={textStyle as React.CSSProperties}>
{item}
</li>
))}
</ul>
);
}
// Check if using disclosure triangle
const isDisclosureTriangle =
resolvedListStyle === "disclosure-closed" ||
resolvedListStyle === "disclosure-open";
if (isDisclosureTriangle) {
// Use inline styles with triangle for disclosure
// Using Unicode escape sequence \25B6 for better backend compatibility
return (
<ul
style={{
listStyle: "none",
paddingLeft: 0,
margin: 0,
}}
>
{items.map((item, idx) => (
<li
key={`${item}-${idx}`}
style={{
position: "relative",
paddingLeft: "1.2rem",
...textStyle,
}}
>
<span
style={{
content: '"\\25B6"',
position: "absolute",
left: 0,
display: "inline-block",
width: "1rem",
}}
>
{String.fromCharCode(0x25b6)}
</span>
{item}
</li>
))}
</ul>
);
}
// Regular list-style rendering
return (
<ul
style={{
listStyleType: resolvedListStyle,
listStylePosition: resolvedListPosition,
paddingLeft: "1.5rem",
margin: 0,
}}
>
{items.map((item, idx) => (
<li key={`${item}-${idx}`} style={textStyle}>
{item}
</li>
))}
</ul>
);
};
return (
<div
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm font-serif text-gray-900 dark:text-gray-100 min-h-96"
style={{
paddingTop: `${margins.top}px`,
paddingRight: `${margins.right}px`,
paddingBottom: `${margins.bottom}px`,
paddingLeft: `${margins.left}px`,
}}
>
{/* Header with its own style */}
{selectedHeader && (
<div
className="mb-6 border-b border-gray-200 dark:border-gray-600 pb-4"
style={headerStyle}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedHeader) }}
/>
)}
{/* Subject */}
<div className="mb-4 text-lg font-semibold" style={subjectStyle}>
<span style={labelStyle(labels.subject.style)}>
{labels.subject[displayLang]}
</span>{" "}
{content.subject || "(Subject)"}
</div>
{/* Date (right aligned) */}
<div className="text-right text-gray-600 dark:text-gray-400 mb-4 text-sm">
<span style={labelStyle(labels.date.style)}>
{labels.date[displayLang]}:
</span>{" "}
{content.date || new Date().toISOString().split("T")[0]}
</div>
{/* To: */}
<div className="mb-2 text-sm">
<span
className="font-semibold text-gray-900 dark:text-white"
style={labelStyle(labels.to.style)}
>
{labels.to[displayLang]}:
</span>{" "}
{renderStyledList(toList, receiverStyle, "(recipients)")}
</div>
{/* From: */}
<div className="mb-4 text-sm" style={fromStyle}>
<span
className="font-semibold text-gray-900 dark:text-white"
style={labelStyle(labels.from.style)}
>
{labels.from[displayLang]}:
</span>{" "}
{fromList.length > 0 ? fromList.join(", ") : "(collaborators)"}
</div>
{/* Body */}
<div className="mb-4 whitespace-pre-wrap text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{renderBodyContent()}
</div>
{/* Sincerely / Closing */}
<div className="mb-2 text-sm">{content.sincerelyText || "Sincerely"}</div>
{/* Delegate signature */}
{content.isWithDelegateSignature && content.delegatorName && (
<div className="mb-2 text-gray-700 dark:text-gray-300 italic text-sm">
<span style={labelStyle(labels.delegate.style)}>
{labels.delegate[displayLang]}
</span>{" "}
{content.delegatorName}
</div>
)}
{/* Prefix / Suffix */}
{(content.prefix || content.suffix) && (
<div className="mb-2 text-xs text-gray-600 dark:text-gray-400">
{content.prefix && <span style={prefixStyle}>{content.prefix}</span>}
{content.prefix && content.suffix && " "}
{content.suffix && <span style={suffixStyle}>{content.suffix}</span>}
</div>
)}
{/* CC lists */}
{(internalCcList.length > 0 || externalCcList.length > 0) && (
<div className="mt-4 pt-2 border-t border-gray-200 dark:border-gray-600 text-xs text-gray-600 dark:text-gray-400">
{internalCcList.length > 0 && (
<div>
<span
className="font-semibold text-gray-900 dark:text-white"
style={labelStyle(labels.cc.style)}
>
{labels.cc[displayLang]}
</span>{" "}
{renderStyledList(internalCcList, ccInternalStyle, "(none)")}
</div>
)}
{externalCcList.length > 0 && (
<div>
<span
className="font-semibold text-gray-900 dark:text-white"
style={labelStyle(labels.cc.style)}
>
{labels.cc[displayLang]}
</span>{" "}
{renderStyledList(externalCcList, ccExternalStyle, "(none)")}
</div>
)}
</div>
)}
{/* Prefix CC / Suffix CC (optional) */}
{(content.prefixCC || content.suffixCC) && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
<span style={ccPrefixStyle}>{content.prefixCC}</span>{" "}
<span style={ccSuffixStyle}>{content.suffixCC}</span>
</div>
)}
{/* Footer with its own style */}
{selectedFooter && (
<div
className="mt-6 border-t border-gray-200 dark:border-gray-600 pt-4"
style={footerStyle}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedFooter) }}
/>
)}
{/* Small indicator of template name / record type */}
<div className="text-xs text-gray-400 dark:text-gray-500 mt-4 text-right">
{templateName && `${templateName}`}
{recordType === "external" ? "External" : "Internal"}
</div>
</div>
);
};

View File

@@ -0,0 +1,105 @@
/**
* Pure CSS utility functions for TemplateSampleForm.
* Extracted here to keep the main form component under 300 lines.
*/
/** Normalize list-style CSS properties — preserves special values like disclosure-closed */
export function normalizeListStyleProps(
obj: Record<string, string>,
): Record<string, string> {
const normalized = { ...obj };
const specialListStyles = ["disclosure-closed", "disclosure-open", "none"];
if (normalized["list-style"]) {
const listStyleValue = normalized["list-style"].split(" ")[0];
if (specialListStyles.includes(listStyleValue)) {
delete normalized["list-style-type"];
return normalized;
}
if (!normalized["list-style-type"]) {
normalized["list-style-type"] = listStyleValue;
}
}
if (normalized["list-style-type"] && !normalized["list-style"]) {
normalized["list-style"] = normalized["list-style-type"];
}
return normalized;
}
/** Serialize a style object to a CSS declaration string */
export function styleObjectToCss(style: Record<string, unknown>): string {
return Object.entries(style)
.filter(([, v]) => v !== null && v !== undefined && v !== "null" && v !== "")
.map(([k, v]) => `${k}: ${String(v)};`)
.join(" ");
}
/**
* Parse CSS declarations from a string, correctly handling data URIs
* (colons and semicolons inside url(...) are not treated as delimiters).
*/
export function parseCSSDeclarations(cssString: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
let depth = 0;
let current = "";
for (let i = 0; i < cssString.length; i++) {
const ch = cssString[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === ";" && depth === 0) {
current = current.trim();
if (current) {
const colonIdx = current.indexOf(":");
if (colonIdx > 0) {
const prop = current.slice(0, colonIdx).trim();
const val = current.slice(colonIdx + 1).trim();
if (prop && val) results.push([prop, val]);
}
}
current = "";
} else {
current += ch;
}
}
// Handle last declaration with no trailing semicolon
current = current.trim();
if (current) {
const colonIdx = current.indexOf(":");
if (colonIdx > 0) {
const prop = current.slice(0, colonIdx).trim();
const val = current.slice(colonIdx + 1).trim();
if (prop && val) results.push([prop, val]);
}
}
return results;
}
/** Convert a CSS string to a normalized style object (data-URI safe) */
export function cssStringToObject(cssString: string): Record<string, string> {
const obj: Record<string, string> = {};
parseCSSDeclarations(cssString).forEach(([prop, val]) => {
obj[prop] = val;
});
return normalizeListStyleProps(obj);
}
/** Convert a simple CSS string to a style object (no data-URI support) */
export function parseCSSStringToObject(cssString: string): Record<string, string> {
const obj: Record<string, string> = {};
cssString
.split(";")
.filter((d) => d.trim())
.forEach((declaration) => {
const [property, value] = declaration.split(":").map((s) => s.trim());
if (property && value) {
obj[property] = value;
}
});
return normalizeListStyleProps(obj);
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Loader, Settings } from "lucide-react"; // Changed icon to Settings
import { TemplateService } from "@/user-management/services/api/templateService";
import { TemplateResource } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
interface TemplateSampleListProps {
onSelectTemplate: (templateId: string) => void;
}
export const TemplateSampleList = ({ onSelectTemplate }: TemplateSampleListProps) => {
const { t } = useTranslation();
const [templates, setTemplates] = useState<TemplateResource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
setLoading(true);
setError(null);
const response = await TemplateService.getAllTemplates();
setTemplates(response.items || []);
} catch (err) {
setError(t("error.loadingTemplates", "Error loading templates"));
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader className="h-8 w-8 animate-spin text-purple-600" />
</div>
);
}
return (
<div className="space-y-6">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400">
{error}
</div>
)}
{templates.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{t("template.noTemplates", "No templates available")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => (
<div
key={template.id}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow"
>
<div className="space-y-3">
<h3 className="font-semibold text-gray-900 dark:text-white">
{template.name?.en || template.name?.am}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
{template.description?.en || template.description?.am}
</p>
{template.fileInfo && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{template.fileInfo.fileName}
</p>
)}
</div>
<button
onClick={() => onSelectTemplate(template.id)}
className="mt-4 w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 dark:hover:bg-purple-500 transition-colors font-medium flex items-center justify-center gap-2"
>
<Settings className="h-4 w-4" />
{t("template.configureStyles", "Configure Styles")}
</button>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,146 @@
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Loader } from "lucide-react";
import { TemplateService } from "@/user-management/services/api/templateService";
import { TemplateResource } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { StyleSettingsPage } from "./StyleSettingsPage";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
export const TemplateSamplePage = ({ unitId }: { unitId?: string }) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [templates, setTemplates] = useState<TemplateResource[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
setLoading(true);
setError(null);
const response = await TemplateService.getAllTemplates();
setTemplates(response.items || []);
const templateFromUrl = searchParams.get("template");
const matchedTemplate = response.items?.find(
(item) => item.id === templateFromUrl,
);
if (matchedTemplate) {
setSelectedTemplateId(matchedTemplate.id);
} else if (response.items?.length > 0) {
const firstId = response.items[0].id;
setSelectedTemplateId(firstId);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("template", firstId);
return next;
},
{ replace: true },
);
}
} catch (err) {
setError(t("error.loadingTemplates", "Error loading templates"));
console.error(err);
} finally {
setLoading(false);
}
};
const handleTemplateChange = (templateId: string) => {
setSelectedTemplateId(templateId);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("template", templateId);
return next;
},
{ replace: true },
);
};
const handleBack = () => {
setSelectedTemplateId(null);
// If you want to go back to content management, you might need to pass a callback.
// For now, we just reset selection (or you could navigate back).
// Since this is directly rendered in ContentManagement, we can keep as is.
// Alternatively, you could clear the selection to show a "select template" message.
// We'll just keep the dropdown so user can choose another template.
// If you need a back button to exit the whole page, add one above.
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader className="h-8 w-8 animate-spin text-purple-600" />
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
);
}
if (templates.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{t("template.noTemplates", "No templates available")}
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Template Selector */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("template.selectTemplate", "Select Template")}
</label>
<Select value={selectedTemplateId || ""} onValueChange={handleTemplateChange}>
<SelectTrigger className="w-64">
<SelectValue placeholder={t("template.selectTemplate")} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name?.en || template.name?.am}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Style Editor */}
{selectedTemplateId && unitId ? (
<StyleSettingsPage
key={`${unitId}-${selectedTemplateId}`}
templateId={selectedTemplateId}
unitId={unitId}
onBack={handleBack}
/>
) : selectedTemplateId && !unitId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
{t("organization.selectUnit", "Please select a unit to edit style settings.")}
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { useTranslation } from "react-i18next";
import { ArrowLeft, Download, Loader, X } from "lucide-react";
interface TemplateSamplePreviewPageProps {
pdfUrl: string;
onBack: () => void;
onSaveSettings: () => void;
isSavingSettings: boolean;
canSaveSettings: boolean;
}
export const TemplateSamplePreviewPage = ({
pdfUrl,
onBack,
onSaveSettings,
isSavingSettings,
canSaveSettings,
}: TemplateSamplePreviewPageProps) => {
const { t } = useTranslation();
const handleDownloadPDF = () => {
if (!pdfUrl) return;
const link = document.createElement("a");
link.href = pdfUrl;
link.download = `template-sample-${Date.now()}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!pdfUrl) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-300 mb-4">
{t("template.noPdfAvailable", "No PDF available")}
</p>
<button
onClick={onBack}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
{t("common.back", "Back")}
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t("template.pdfPreview", "PDF Preview")}
</h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={onSaveSettings}
disabled={isSavingSettings || !canSaveSettings}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-gray-400 dark:disabled:bg-gray-600 transition-colors font-medium flex items-center gap-2"
>
{isSavingSettings ? (
<>
<Loader className="h-4 w-4 animate-spin" />
{t("common.saving", "Saving...")}
</>
) : (
t("template.saveSettings", "Save Settings")
)}
</button>
<button
onClick={handleDownloadPDF}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium flex items-center gap-2"
>
<Download className="h-4 w-4" />
{t("common.download", "Download")}
</button>
<button
onClick={onBack}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
</div>
{/* PDF Viewer */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-lg">
<div className="w-full h-[calc(100vh-200px)] bg-gray-50 dark:bg-gray-700">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={t("template.pdfPreview", "PDF Preview")}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TemplateService } from "@/user-management/services/api/templateService";
import { TemplateSampleSetting } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { FONT_RESOURCE_TYPE_ID } from "../utils/templateResourceConstants";
import {
StyleSettingCard,
StyleSettingSelect,
} from "./styleSettingsUi";
const EMPTY_FONT_VALUE = "__none__";
export const isFontSetting = (code: string): boolean =>
code.trim().toLowerCase() === "letter-fonts";
type Props = {
setting: TemplateSampleSetting;
isDefault: boolean;
resourceId: string | null;
onToggleDefault: (settingId: string) => void;
onResourceIdChange: (resourceId: string | null) => void;
};
export const FontSettingsEditor: React.FC<Props> = ({
setting,
isDefault,
resourceId,
onToggleDefault,
onResourceIdChange,
}) => {
const { t, i18n } = useTranslation();
const [fonts, setFonts] = useState<
Array<{ id: string; name?: { en?: string; am?: string } }>
>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const loadFonts = async () => {
setLoading(true);
try {
const response = await TemplateService.getTemplates({
resourceTypeId: FONT_RESOURCE_TYPE_ID,
});
if (!cancelled) setFonts(response.items);
} catch (error) {
console.error("Failed to load font resources", error);
} finally {
if (!cancelled) setLoading(false);
}
};
void loadFonts();
return () => {
cancelled = true;
};
}, []);
const locale = i18n.language?.startsWith("am") ? "am" : "en";
return (
<StyleSettingCard
title={t("template.fonts", "Fonts")}
subtitle={t("template.fontsHint", "Select an optional font resource.")}
code={setting.code}
isDefault={isDefault}
onToggleDefault={() => onToggleDefault(setting.id)}
>
<fieldset disabled={isDefault}>
<StyleSettingSelect
label={t("template.fontResource", "Font file")}
value={resourceId ?? EMPTY_FONT_VALUE}
disabled={loading || isDefault}
onValueChange={(value) =>
onResourceIdChange(value === EMPTY_FONT_VALUE ? null : value)
}
placeholder={t("template.selectFont", "Select a font")}
options={[
{
value: EMPTY_FONT_VALUE,
label: t("template.selectFont", "Select a font"),
},
...fonts.map((font) => ({
value: font.id,
label: font.name?.[locale] || font.name?.en || font.id,
})),
]}
/>
</fieldset>
</StyleSettingCard>
);
};

View File

@@ -0,0 +1,789 @@
import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TemplateSampleSetting } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import type { PageMarginValue } from "./PageMarginEditor";
import {
LetterAssetsPlacementCanvas,
normalizeAbsoluteAssetOrder,
type LetterAbsoluteAssetKey,
} from "./LetterAssetsPlacementCanvas";
import {
normalizeLayoutPositionValue,
toAbsolutePayloadPosition,
buildDefaultPositionsByLanguage,
type LayoutPositionValue,
type LetterLanguage,
} from "./layout-position";
export const LAYOUT_CONFIG_SETTING_CODE = "letter-layout-config";
export type LetterLayoutSectionKey =
| "reference"
| "receivers"
| "sender"
| "subject"
| "body"
| "closing"
| "cc"
| "forYourReference";
export type LetterLayoutHorizontalAlign = "auto" | "start" | "center" | "end";
export type LetterClosingAssetKey = "signatures" | "seal";
export type LetterSenderAssetKey = "signature" | "stamp";
export type LetterStampPlacement = "below-last-signature" | "beside-signatures";
export type LetterClosingLanguagePositions = {
signatures: LayoutPositionValue;
seal: LayoutPositionValue;
stamp: LayoutPositionValue;
};
export type LetterLayoutPositionsByLanguage = {
am: LetterClosingLanguagePositions;
en: LetterClosingLanguagePositions;
};
export type LetterLayoutConfigValue = {
sectionOrder: LetterLayoutSectionKey[];
signatures: {
widthPt: number;
heightPx: number;
gapPx: number;
align: LetterLayoutHorizontalAlign;
position: LayoutPositionValue;
};
seal: { widthPx: number; heightPx: number; position: LayoutPositionValue };
stamp: {
widthPt: number;
heightPx: number;
placement: LetterStampPlacement;
position: LayoutPositionValue;
};
sender: {
align: LetterLayoutHorizontalAlign;
assetOrder: LetterSenderAssetKey[];
signature: LayoutPositionValue;
stamp: LayoutPositionValue;
};
closingAssetOrder: LetterClosingAssetKey[];
absoluteAssetOrder: LetterAbsoluteAssetKey[];
positionsByLanguage: LetterLayoutPositionsByLanguage;
};
const ALL_SECTION_KEYS: LetterLayoutSectionKey[] = [
"reference",
"receivers",
"sender",
"subject",
"body",
"closing",
"cc",
"forYourReference",
];
const DEFAULT_POSITIONS_BY_LANGUAGE = buildDefaultPositionsByLanguage();
const DEFAULT_LAYOUT_CONFIG: LetterLayoutConfigValue = {
sectionOrder: [...ALL_SECTION_KEYS],
signatures: {
widthPt: 96,
heightPx: 50,
gapPx: 20,
align: "auto",
position: { ...DEFAULT_POSITIONS_BY_LANGUAGE.am.signatures },
},
seal: {
widthPx: 140,
heightPx: 140,
position: { ...DEFAULT_POSITIONS_BY_LANGUAGE.am.seal },
},
stamp: {
widthPt: 96,
heightPx: 50,
placement: "below-last-signature",
position: { ...DEFAULT_POSITIONS_BY_LANGUAGE.am.stamp },
},
sender: {
align: "auto",
assetOrder: ["signature", "stamp"],
signature: normalizeLayoutPositionValue(null, "senderSignature"),
stamp: normalizeLayoutPositionValue(null, "senderStamp"),
},
closingAssetOrder: ["signatures", "seal"],
absoluteAssetOrder: [
"signatures",
"seal",
"stamp",
"senderSignature",
"senderStamp",
],
positionsByLanguage: DEFAULT_POSITIONS_BY_LANGUAGE,
};
const HORIZONTAL_ALIGNS: LetterLayoutHorizontalAlign[] = [
"auto",
"start",
"center",
"end",
];
const STAMP_PLACEMENTS: LetterStampPlacement[] = [
"below-last-signature",
"beside-signatures",
];
const CLOSING_ASSET_KEYS: LetterClosingAssetKey[] = ["signatures", "seal"];
const SENDER_ASSET_KEYS: LetterSenderAssetKey[] = ["signature", "stamp"];
function parsePositiveNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value.trim());
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return fallback;
}
function isSectionKey(value: unknown): value is LetterLayoutSectionKey {
return (
typeof value === "string" &&
ALL_SECTION_KEYS.includes(value as LetterLayoutSectionKey)
);
}
function parseAlign(value: unknown, fallback: LetterLayoutHorizontalAlign): LetterLayoutHorizontalAlign {
return typeof value === "string" &&
HORIZONTAL_ALIGNS.includes(value as LetterLayoutHorizontalAlign)
? (value as LetterLayoutHorizontalAlign)
: fallback;
}
function parseStampPlacement(value: unknown): LetterStampPlacement {
return typeof value === "string" &&
STAMP_PLACEMENTS.includes(value as LetterStampPlacement)
? (value as LetterStampPlacement)
: DEFAULT_LAYOUT_CONFIG.stamp.placement;
}
function parseClosingAssetOrder(value: unknown): LetterClosingAssetKey[] {
if (!Array.isArray(value)) return [...DEFAULT_LAYOUT_CONFIG.closingAssetOrder];
const seen = new Set<LetterClosingAssetKey>();
const order: LetterClosingAssetKey[] = [];
for (const item of value) {
if (
(item === "signatures" || item === "seal") &&
!seen.has(item as LetterClosingAssetKey)
) {
seen.add(item as LetterClosingAssetKey);
order.push(item as LetterClosingAssetKey);
}
}
for (const key of CLOSING_ASSET_KEYS) {
if (!seen.has(key)) order.push(key);
}
return order;
}
function parseSenderAssetOrder(value: unknown): LetterSenderAssetKey[] {
if (!Array.isArray(value)) return [...DEFAULT_LAYOUT_CONFIG.sender.assetOrder];
const seen = new Set<LetterSenderAssetKey>();
const order: LetterSenderAssetKey[] = [];
for (const item of value) {
if (
(item === "signature" || item === "stamp") &&
!seen.has(item as LetterSenderAssetKey)
) {
seen.add(item as LetterSenderAssetKey);
order.push(item as LetterSenderAssetKey);
}
}
for (const key of SENDER_ASSET_KEYS) {
if (!seen.has(key)) order.push(key);
}
return order;
}
function parseClosingLanguagePositions(
raw: unknown,
language: LetterLanguage,
fallback: LetterClosingLanguagePositions,
flatFallback?: Partial<LetterClosingLanguagePositions>,
): LetterClosingLanguagePositions {
const record =
raw != null && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
return {
signatures: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(
record.signatures ??
flatFallback?.signatures ??
fallback.signatures,
"signatures",
undefined,
language,
),
) as unknown as LayoutPositionValue,
seal: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(
record.seal ?? flatFallback?.seal ?? fallback.seal,
"seal",
undefined,
language,
),
) as unknown as LayoutPositionValue,
stamp: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(
record.stamp ?? flatFallback?.stamp ?? fallback.stamp,
"stamp",
undefined,
language,
),
) as unknown as LayoutPositionValue,
};
}
function parsePositionsByLanguage(
record: Record<string, unknown>,
flat: LetterClosingLanguagePositions,
): LetterLayoutPositionsByLanguage {
const defaults = buildDefaultPositionsByLanguage();
const raw = record.positionsByLanguage;
if (raw != null && typeof raw === "object" && !Array.isArray(raw)) {
const byLang = raw as Record<string, unknown>;
return {
am: parseClosingLanguagePositions(byLang.am, "am", defaults.am),
en: parseClosingLanguagePositions(byLang.en, "en", defaults.en),
};
}
return {
am: parseClosingLanguagePositions(undefined, "am", defaults.am, flat),
en: parseClosingLanguagePositions(undefined, "en", defaults.en),
};
}
export function normalizeLayoutConfigValue(
value: unknown,
): LetterLayoutConfigValue {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return {
sectionOrder: [...DEFAULT_LAYOUT_CONFIG.sectionOrder],
signatures: { ...DEFAULT_LAYOUT_CONFIG.signatures },
seal: { ...DEFAULT_LAYOUT_CONFIG.seal },
stamp: { ...DEFAULT_LAYOUT_CONFIG.stamp },
sender: {
...DEFAULT_LAYOUT_CONFIG.sender,
signature: { ...DEFAULT_LAYOUT_CONFIG.sender.signature },
stamp: { ...DEFAULT_LAYOUT_CONFIG.sender.stamp },
},
closingAssetOrder: [...DEFAULT_LAYOUT_CONFIG.closingAssetOrder],
absoluteAssetOrder: [...DEFAULT_LAYOUT_CONFIG.absoluteAssetOrder],
positionsByLanguage: buildDefaultPositionsByLanguage(),
};
}
const record = value as Record<string, unknown>;
const seen = new Set<LetterLayoutSectionKey>();
const order: LetterLayoutSectionKey[] = [];
if (Array.isArray(record.sectionOrder)) {
for (const item of record.sectionOrder) {
if (!isSectionKey(item) || seen.has(item)) continue;
seen.add(item);
order.push(item);
}
}
for (const key of ALL_SECTION_KEYS) {
if (!seen.has(key)) order.push(key);
}
const signatures =
record.signatures != null &&
typeof record.signatures === "object" &&
!Array.isArray(record.signatures)
? (record.signatures as Record<string, unknown>)
: {};
const seal =
record.seal != null &&
typeof record.seal === "object" &&
!Array.isArray(record.seal)
? (record.seal as Record<string, unknown>)
: {};
const stamp =
record.stamp != null &&
typeof record.stamp === "object" &&
!Array.isArray(record.stamp)
? (record.stamp as Record<string, unknown>)
: {};
const sender =
record.sender != null &&
typeof record.sender === "object" &&
!Array.isArray(record.sender)
? (record.sender as Record<string, unknown>)
: {};
const flatFromRecord: LetterClosingLanguagePositions = {
signatures: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(signatures.position, "signatures"),
) as unknown as LayoutPositionValue,
seal: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(seal.position, "seal"),
) as unknown as LayoutPositionValue,
stamp: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(stamp.position, "stamp"),
) as unknown as LayoutPositionValue,
};
const positionsByLanguage = parsePositionsByLanguage(record, flatFromRecord);
return {
sectionOrder: order,
signatures: {
widthPt: parsePositiveNumber(
signatures.widthPt,
DEFAULT_LAYOUT_CONFIG.signatures.widthPt,
),
heightPx: parsePositiveNumber(
signatures.heightPx,
DEFAULT_LAYOUT_CONFIG.signatures.heightPx,
),
gapPx: parsePositiveNumber(
signatures.gapPx,
DEFAULT_LAYOUT_CONFIG.signatures.gapPx,
),
align: parseAlign(
signatures.align,
DEFAULT_LAYOUT_CONFIG.signatures.align,
),
position: { ...positionsByLanguage.am.signatures },
},
seal: {
widthPx: parsePositiveNumber(
seal.widthPx,
DEFAULT_LAYOUT_CONFIG.seal.widthPx,
),
heightPx: parsePositiveNumber(
seal.heightPx,
DEFAULT_LAYOUT_CONFIG.seal.heightPx,
),
position: { ...positionsByLanguage.am.seal },
},
stamp: {
widthPt: parsePositiveNumber(
stamp.widthPt,
DEFAULT_LAYOUT_CONFIG.stamp.widthPt,
),
heightPx: parsePositiveNumber(
stamp.heightPx,
DEFAULT_LAYOUT_CONFIG.stamp.heightPx,
),
placement: parseStampPlacement(stamp.placement),
position: { ...positionsByLanguage.am.stamp },
},
sender: {
align: parseAlign(sender.align, DEFAULT_LAYOUT_CONFIG.sender.align),
assetOrder: parseSenderAssetOrder(sender.assetOrder),
signature: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(sender.signature, "senderSignature"),
) as unknown as LayoutPositionValue,
stamp: toAbsolutePayloadPosition(
normalizeLayoutPositionValue(sender.stamp, "senderStamp"),
) as unknown as LayoutPositionValue,
},
closingAssetOrder: parseClosingAssetOrder(record.closingAssetOrder),
absoluteAssetOrder: normalizeAbsoluteAssetOrder(record.absoluteAssetOrder),
positionsByLanguage,
};
}
export function isLayoutConfigSetting(code: string): boolean {
return code.trim().toLowerCase() === LAYOUT_CONFIG_SETTING_CODE;
}
interface LayoutConfigEditorProps {
setting: TemplateSampleSetting;
isDefault: boolean;
onToggleDefault: (settingId: string) => void;
value: LetterLayoutConfigValue;
onChange: (value: LetterLayoutConfigValue) => void;
appliesTo?: { en: string; am: string };
pageMargin?: PageMarginValue;
placementLanguage?: LetterLanguage;
placementFocusNonce?: number;
/** Record override panel: inherit unit values but keep controls enabled. */
editorMode?: "template" | "record";
/** Larger canvas when embedded in record preview dialog. */
canvasLayoutMode?: "default" | "preview";
}
export const LayoutConfigEditor: React.FC<LayoutConfigEditorProps> = ({
setting,
isDefault,
onToggleDefault,
value,
onChange,
appliesTo,
pageMargin,
placementLanguage = "am",
placementFocusNonce = 0,
editorMode = "template",
canvasLayoutMode = "default",
}) => {
const { t, i18n } = useTranslation();
const displayLang = i18n.language?.startsWith("am") ? "am" : "en";
const isRecordMode = editorMode === "record";
const controlsDisabled = isDefault && !isRecordMode;
const sectionLabels = useMemo(
(): Record<LetterLayoutSectionKey, string> => ({
reference: t("template.sectionReference", "Reference & date"),
receivers: t("template.sectionReceivers", "Receivers"),
sender: t("template.sectionSender", "Sender"),
subject: t("template.sectionSubject", "Subject"),
body: t("template.sectionBody", "Body"),
closing: t("template.sectionClosing", "Closing & signatures"),
cc: t("template.sectionCc", "CC"),
forYourReference: t(
"template.sectionForYourReference",
"For your reference",
),
}),
[t],
);
const moveSection = useCallback(
(index: number, direction: -1 | 1) => {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= value.sectionOrder.length) return;
const order = [...value.sectionOrder];
const [item] = order.splice(index, 1);
order.splice(nextIndex, 0, item);
onChange({ ...value, sectionOrder: order });
},
[value, onChange],
);
const alignLabels = useMemo(
(): Record<LetterLayoutHorizontalAlign, string> => ({
auto: t("template.alignAuto", "Auto (language default)"),
start: t("template.alignStart", "Start (left)"),
center: t("template.alignCenter", "Center"),
end: t("template.alignEnd", "End (right)"),
}),
[t],
);
const updateNumber = useCallback(
(
group: "signatures" | "seal" | "stamp",
field: string,
raw: string,
) => {
const parsed = Number.parseFloat(raw);
const num = Number.isNaN(parsed) ? 1 : Math.max(1, parsed);
onChange({
...value,
[group]: {
...value[group],
[field]: num,
},
});
},
[value, onChange],
);
return (
<div
className={
isRecordMode
? "min-w-0 flex flex-col flex-1 min-h-0"
: "mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 space-y-6"
}
>
{!isRecordMode && (
<div className="flex items-center justify-between gap-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-200">
{t("template.letterLayout", "Letter Layout")}
</h4>
{appliesTo && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("template.appliesTo", "Applies to")}: {appliesTo[displayLang]}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
"template.letterLayoutHint",
"Place signatures, seal, and stamp on the page canvas first, then adjust sizes and section order below.",
)}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer shrink-0">
<input
type="checkbox"
checked={isDefault}
onChange={() => onToggleDefault(setting.id)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-500"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{isDefault
? t("template.useGlobalDefault", "Use template default")
: t("template.unitOverride", "Unit override")}
</span>
</label>
</div>
)}
<fieldset
disabled={controlsDisabled}
className={`border-0 p-0 m-0 min-w-0 ${controlsDisabled ? "opacity-60" : ""} ${
isRecordMode
? "flex flex-col flex-1 min-h-0 space-y-3"
: "space-y-6"
}`}
>
{!isRecordMode && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-300">
{t("template.placementLanguage", "Placement language")}:{" "}
<strong>
{placementLanguage === "am"
? t("template.languageAm", "Amharic")
: t("template.languageEn", "English")}
</strong>
</span>
<button
type="button"
disabled={controlsDisabled}
onClick={() =>
onChange({
...value,
positionsByLanguage: {
...value.positionsByLanguage,
en: { ...value.positionsByLanguage.am },
},
})
}
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-500 disabled:opacity-40"
>
{t("template.copyAmToEnPositions", "Copy Amharic → English")}
</button>
</div>
)}
<LetterAssetsPlacementCanvas
value={value}
onChange={onChange}
disabled={controlsDisabled}
pageMargin={pageMargin}
placementLanguage={placementLanguage}
placementFocusNonce={placementFocusNonce}
layoutMode={
canvasLayoutMode !== "default"
? canvasLayoutMode
: isRecordMode
? "preview"
: "default"
}
/>
{!isRecordMode && (
<>
<div className="border-t border-gray-200 pt-6 dark:border-gray-600">
<h5 className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
{t("template.layoutOptions", "Layout options")}
</h5>
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
{t(
"template.layoutOptionsHint",
"Optional: reorder letter sections and fine-tune asset dimensions.",
)}
</p>
</div>
<div>
<h5 className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide">
{t("template.sectionOrder", "Section order")}
</h5>
<ul className="space-y-2">
{value.sectionOrder.map((key, index) => (
<li
key={key}
className="flex items-center justify-between gap-2 px-3 py-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600"
>
<span className="text-sm text-gray-700 dark:text-gray-200">
{index + 1}. {sectionLabels[key]}
</span>
<div className="flex gap-1 shrink-0">
<button
type="button"
onClick={() => moveSection(index, -1)}
disabled={index === 0}
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-500 disabled:opacity-40"
aria-label={t("template.moveUp", "Move up")}
>
</button>
<button
type="button"
onClick={() => moveSection(index, 1)}
disabled={index === value.sectionOrder.length - 1}
className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-500 disabled:opacity-40"
aria-label={t("template.moveDown", "Move down")}
>
</button>
</div>
</li>
))}
</ul>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<h5 className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide">
{t("template.signatures", "Signatures")}
</h5>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.signatureWidth", "Width (pt)")}
</label>
<input
type="number"
min={1}
value={value.signatures.widthPt}
onChange={(e) =>
updateNumber("signatures", "widthPt", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.signatureHeight", "Height (px)")}
</label>
<input
type="number"
min={1}
value={value.signatures.heightPx}
onChange={(e) =>
updateNumber("signatures", "heightPx", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.signatureGap", "Gap (px)")}
</label>
<input
type="number"
min={1}
value={value.signatures.gapPx}
onChange={(e) =>
updateNumber("signatures", "gapPx", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.closingAlign", "Closing block horizontal position")}
</label>
<select
value={value.signatures.align}
onChange={(e) =>
onChange({
...value,
signatures: {
...value.signatures,
align: e.target.value as LetterLayoutHorizontalAlign,
},
})
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
>
{HORIZONTAL_ALIGNS.map((align) => (
<option key={align} value={align}>
{alignLabels[align]}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<h5 className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide">
{t("template.seal", "Seal")}
</h5>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.sealWidth", "Width (px)")}
</label>
<input
type="number"
min={1}
value={value.seal.widthPx}
onChange={(e) => updateNumber("seal", "widthPx", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.sealHeight", "Height (px)")}
</label>
<input
type="number"
min={1}
value={value.seal.heightPx}
onChange={(e) =>
updateNumber("seal", "heightPx", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<h5 className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2 uppercase tracking-wide">
{t("template.stamp", "Stamp (teeter)")}
</h5>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.stampWidth", "Width (pt)")}
</label>
<input
type="number"
min={1}
value={value.stamp.widthPt}
onChange={(e) =>
updateNumber("stamp", "widthPt", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.stampHeight", "Height (px)")}
</label>
<input
type="number"
min={1}
value={value.stamp.heightPx}
onChange={(e) =>
updateNumber("stamp", "heightPx", e.target.value)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm"
/>
</div>
</div>
</>
)}
</fieldset>
</div>
);
};

View File

@@ -0,0 +1,647 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { LetterLayoutConfigValue } from "./LayoutConfigEditor";
import {
fromCanvasDisplayPosition,
getLetterCanvasSize,
type LayoutPositionValue,
normalizeLayoutPositionValue,
toCanvasDisplayPosition,
type LetterLanguage,
} from "./layout-position";
import type { PageMarginValue } from "./PageMarginEditor";
import { normalizePageMarginValue } from "./PageMarginEditor";
import {
estimateClosingBlockTopPx,
} from "../utils/letter-page-dimensions";
export type LetterAbsoluteAssetKey =
| "signatures"
| "seal"
| "stamp"
| "senderSignature"
| "senderStamp";
export const LETTER_ABSOLUTE_ASSET_KEYS: LetterAbsoluteAssetKey[] = [
"signatures",
"seal",
"stamp",
"senderSignature",
"senderStamp",
];
export const LETTER_ASSET_PLACEMENT_ELEMENT_ID = "letter-asset-placement";
/** Closing assets shown in style-settings placement UI (sender header assets hidden). */
export const LETTER_PLACEMENT_CANVAS_ASSET_KEYS: LetterAbsoluteAssetKey[] = [
"signatures",
"seal",
"stamp",
];
const ASSET_COLORS: Record<LetterAbsoluteAssetKey, string> = {
signatures: "border-blue-500 bg-blue-500/20",
seal: "border-emerald-500 bg-emerald-500/20",
stamp: "border-amber-500 bg-amber-500/20",
senderSignature: "border-violet-500 bg-violet-500/20",
senderStamp: "border-rose-500 bg-rose-500/20",
};
function canvasRectToReference(
rect: DOMRect,
clientX: number,
clientY: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const scaleX = canvasWidth / rect.width;
const scaleY = canvasHeight / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
};
}
function ptToPx(pt: number): number {
return Math.round((pt * 96) / 72);
}
type LetterAssetsPlacementCanvasProps = {
value: LetterLayoutConfigValue;
onChange: (value: LetterLayoutConfigValue) => void;
disabled?: boolean;
pageMargin?: PageMarginValue;
placementLanguage?: LetterLanguage;
placementFocusNonce?: number;
/** Preview dialog: larger canvas area, controls below. */
layoutMode?: "default" | "preview";
};
function isClosingAssetKey(
key: LetterAbsoluteAssetKey,
): key is "signatures" | "seal" | "stamp" {
return key === "signatures" || key === "seal" || key === "stamp";
}
function getAssetPosition(
config: LetterLayoutConfigValue,
key: LetterAbsoluteAssetKey,
language: LetterLanguage,
): LayoutPositionValue {
if (isClosingAssetKey(key)) {
return config.positionsByLanguage[language][key];
}
switch (key) {
case "senderSignature":
return config.sender.signature;
case "senderStamp":
return config.sender.stamp;
default: {
const unreachable: never = key;
return unreachable;
}
}
}
function setAssetPosition(
config: LetterLayoutConfigValue,
key: LetterAbsoluteAssetKey,
position: LayoutPositionValue,
language: LetterLanguage,
): LetterLayoutConfigValue {
if (isClosingAssetKey(key)) {
const positionsByLanguage = {
...config.positionsByLanguage,
[language]: {
...config.positionsByLanguage[language],
[key]: position,
},
};
const next: LetterLayoutConfigValue = {
...config,
positionsByLanguage,
};
if (language === "am") {
if (key === "signatures") {
next.signatures = { ...config.signatures, position };
} else if (key === "seal") {
next.seal = { ...config.seal, position };
} else if (key === "stamp") {
next.stamp = { ...config.stamp, position };
}
}
return next;
}
switch (key) {
case "senderSignature":
return {
...config,
sender: { ...config.sender, signature: position },
};
case "senderStamp":
return {
...config,
sender: { ...config.sender, stamp: position },
};
default: {
const unreachable: never = key;
return unreachable;
}
}
}
function getAssetSize(
config: LetterLayoutConfigValue,
key: LetterAbsoluteAssetKey,
): { widthPx: number; heightPx: number } {
switch (key) {
case "signatures":
return {
widthPx: ptToPx(config.signatures.widthPt) * 2 + config.signatures.gapPx,
heightPx: config.signatures.heightPx + 24,
};
case "seal":
return {
widthPx: config.seal.widthPx,
heightPx: config.seal.heightPx,
};
case "stamp":
return {
widthPx: ptToPx(config.stamp.widthPt),
heightPx: config.stamp.heightPx,
};
case "senderSignature":
return {
widthPx: ptToPx(config.signatures.widthPt),
heightPx: config.signatures.heightPx,
};
case "senderStamp":
return {
widthPx: ptToPx(config.stamp.widthPt),
heightPx: config.stamp.heightPx,
};
}
}
export function normalizeAbsoluteAssetOrder(
value: unknown,
): LetterAbsoluteAssetKey[] {
if (!Array.isArray(value)) return [...LETTER_ABSOLUTE_ASSET_KEYS];
const seen = new Set<LetterAbsoluteAssetKey>();
const order: LetterAbsoluteAssetKey[] = [];
for (const item of value) {
if (
typeof item === "string" &&
LETTER_ABSOLUTE_ASSET_KEYS.includes(item as LetterAbsoluteAssetKey) &&
!seen.has(item as LetterAbsoluteAssetKey)
) {
seen.add(item as LetterAbsoluteAssetKey);
order.push(item as LetterAbsoluteAssetKey);
}
}
for (const key of LETTER_ABSOLUTE_ASSET_KEYS) {
if (!seen.has(key)) order.push(key);
}
return order;
}
export const LetterAssetsPlacementCanvas: React.FC<
LetterAssetsPlacementCanvasProps
> = ({
value,
onChange,
disabled = false,
pageMargin,
placementLanguage = "am",
placementFocusNonce = 0,
layoutMode = "default",
}) => {
const { t } = useTranslation();
const isPreviewLayout = layoutMode === "preview";
const canvasRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ pointerId: number; assetKey: LetterAbsoluteAssetKey } | null>(
null,
);
const [selectedKey, setSelectedKey] =
useState<LetterAbsoluteAssetKey>("signatures");
const margins = useMemo(
() => normalizePageMarginValue(pageMargin),
[pageMargin],
);
const { width: canvasWidth, height: canvasHeight } = useMemo(
() => getLetterCanvasSize(margins),
[margins],
);
const layerOrder = useMemo(
() =>
normalizeAbsoluteAssetOrder(value.absoluteAssetOrder).filter((key) =>
LETTER_PLACEMENT_CANVAS_ASSET_KEYS.includes(key),
),
[value.absoluteAssetOrder],
);
const assetLabels = useMemo(
(): Record<LetterAbsoluteAssetKey, string> => ({
signatures: t("template.closingAssetSignatures", "Signature block"),
seal: t("template.closingAssetSeal", "Seal"),
stamp: t("template.stamp", "Stamp (teeter)"),
senderSignature: t("template.senderAssetSignature", "Sender signature"),
senderStamp: t("template.senderAssetStamp", "Sender stamp"),
}),
[t],
);
const selectedPosition = getAssetPosition(
value,
selectedKey,
placementLanguage,
);
const selectedCanvasPosition = toCanvasDisplayPosition(
selectedKey,
selectedPosition,
margins,
);
const selectedTop =
typeof selectedCanvasPosition.top === "number"
? selectedCanvasPosition.top
: 0;
const selectedLeft =
typeof selectedCanvasPosition.left === "number"
? selectedCanvasPosition.left
: 0;
const closingBlockTop = useMemo(
() => estimateClosingBlockTopPx(margins),
[margins],
);
const scrollCanvasToAssets = useCallback(() => {
const container = scrollContainerRef.current;
if (!container || layerOrder.length === 0) return;
let minLeft = Infinity;
let minTop = Infinity;
let maxRight = -Infinity;
let maxBottom = -Infinity;
for (const assetKey of layerOrder) {
const storedPosition = getAssetPosition(
value,
assetKey,
placementLanguage,
);
const position = toCanvasDisplayPosition(
assetKey,
storedPosition,
margins,
);
const { widthPx, heightPx } = getAssetSize(value, assetKey);
const top = typeof position.top === "number" ? position.top : 0;
const left = typeof position.left === "number" ? position.left : 0;
minLeft = Math.min(minLeft, left);
minTop = Math.min(minTop, top);
maxRight = Math.max(maxRight, left + widthPx);
maxBottom = Math.max(maxBottom, top + heightPx);
}
if (!Number.isFinite(minLeft)) return;
const boxCenterX = (minLeft + maxRight) / 2;
const boxCenterY = (minTop + maxBottom) / 2;
container.scrollLeft = Math.max(
0,
boxCenterX - container.clientWidth / 2,
);
container.scrollTop = Math.max(
0,
boxCenterY - container.clientHeight / 2,
);
}, [layerOrder, margins, placementLanguage, value]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
scrollCanvasToAssets();
});
return () => cancelAnimationFrame(frame);
}, [scrollCanvasToAssets, placementLanguage, placementFocusNonce]);
const placeAsset = useCallback(
(
assetKey: LetterAbsoluteAssetKey,
clientX: number,
clientY: number,
) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ref = canvasRectToReference(
rect,
clientX,
clientY,
canvasWidth,
canvasHeight,
);
const { widthPx, heightPx } = getAssetSize(value, assetKey);
const canvasPosition = normalizeLayoutPositionValue({
top: Math.round(ref.y - heightPx / 2),
left: Math.round(ref.x - widthPx / 2),
});
const storedPosition = fromCanvasDisplayPosition(
assetKey,
canvasPosition,
margins,
);
onChange(setAssetPosition(value, assetKey, storedPosition, placementLanguage));
},
[canvasHeight, canvasWidth, margins, onChange, placementLanguage, value],
);
const handleAssetPointerDown = (
event: React.PointerEvent<HTMLDivElement>,
assetKey: LetterAbsoluteAssetKey,
) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
setSelectedKey(assetKey);
dragRef.current = { pointerId: event.pointerId, assetKey };
event.currentTarget.setPointerCapture(event.pointerId);
placeAsset(assetKey, event.clientX, event.clientY);
};
const handleCanvasPointerDown = (
event: React.PointerEvent<HTMLDivElement>,
) => {
if (disabled) return;
placeAsset(selectedKey, event.clientX, event.clientY);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || dragRef.current?.pointerId !== event.pointerId) return;
placeAsset(dragRef.current.assetKey, event.clientX, event.clientY);
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
};
const updateSelectedCoord = (key: "top" | "left", raw: string) => {
const trimmed = raw.trim();
if (trimmed === "" || trimmed === "-") return;
const parsed = Number.parseFloat(trimmed);
if (!Number.isFinite(parsed)) return;
const nextCanvas = {
top: key === "top" ? Math.round(parsed) : selectedTop,
left: key === "left" ? Math.round(parsed) : selectedLeft,
};
const position = fromCanvasDisplayPosition(
selectedKey,
{ ...selectedPosition, ...nextCanvas },
margins,
);
onChange(setAssetPosition(value, selectedKey, position, placementLanguage));
};
const moveLayer = (index: number, direction: -1 | 1) => {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= layerOrder.length) return;
const order = [...layerOrder];
const [item] = order.splice(index, 1);
order.splice(nextIndex, 0, item);
const hiddenOrder = normalizeAbsoluteAssetOrder(
value.absoluteAssetOrder,
).filter((key) => !LETTER_PLACEMENT_CANVAS_ASSET_KEYS.includes(key));
onChange({
...value,
absoluteAssetOrder: [...order, ...hiddenOrder],
});
};
return (
<div
id={LETTER_ASSET_PLACEMENT_ELEMENT_ID}
className={`space-y-3 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-3 ${
isPreviewLayout ? "h-full flex flex-col min-h-0" : ""
}`}
>
<div className={isPreviewLayout ? "shrink-0" : undefined}>
<h5 className="text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide">
{t("template.assetPlacementCanvas", "Asset placement")}
</h5>
<p className="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
{t(
"template.assetPlacementCanvasHint",
"Drag each asset on the page. Editing positions for {{language}}. Canvas matches PDF content area ({{width}}×{{height}} px).",
{
language:
placementLanguage === "am"
? t("template.languageAm", "Amharic")
: t("template.languageEn", "English"),
width: canvasWidth,
height: canvasHeight,
},
)}
</p>
</div>
<div
className={
isPreviewLayout
? "flex flex-col gap-3 flex-1 min-h-0"
: "grid grid-cols-1 lg:grid-cols-[1fr_220px] gap-3"
}
>
<div
ref={scrollContainerRef}
className={
isPreviewLayout
? "flex-1 min-h-0 overflow-auto rounded-md border border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-900/50 p-3 flex items-start justify-center"
: "overflow-auto max-h-[560px] rounded-md border border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-900/50 p-2"
}
>
<div
ref={canvasRef}
className={`relative touch-none select-none bg-white dark:bg-gray-900/60 ${
disabled ? "cursor-not-allowed opacity-60" : "cursor-crosshair"
}`}
style={{
width: canvasWidth,
height: canvasHeight,
minWidth: canvasWidth,
minHeight: canvasHeight,
overflow: "visible",
}}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="absolute inset-0 border border-dashed border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900/40 pointer-events-none" />
<div
className="absolute left-0 right-0 border-t border-dashed border-purple-300 dark:border-purple-600 pointer-events-none"
style={{
top: `${(closingBlockTop / canvasHeight) * 100}%`,
}}
title={t("template.closingBlockGuide", "Sincerely section (approx.)")}
/>
{layerOrder.map((assetKey, layerIndex) => {
const storedPosition = getAssetPosition(
value,
assetKey,
placementLanguage,
);
const position = toCanvasDisplayPosition(
assetKey,
storedPosition,
margins,
);
const { widthPx, heightPx } = getAssetSize(value, assetKey);
const top =
typeof position.top === "number" ? position.top : 0;
const left =
typeof position.left === "number" ? position.left : 0;
const isSelected = selectedKey === assetKey;
return (
<div
key={assetKey}
className={`absolute rounded border-2 touch-none ${
ASSET_COLORS[assetKey]
} ${isSelected ? "ring-2 ring-purple-500 ring-offset-1" : ""} ${
disabled
? "cursor-not-allowed"
: "cursor-grab active:cursor-grabbing"
}`}
style={{
left: `${(left / canvasWidth) * 100}%`,
top: `${(top / canvasHeight) * 100}%`,
width: `${(widthPx / canvasWidth) * 100}%`,
height: `${(heightPx / canvasHeight) * 100}%`,
zIndex: layerIndex + 1,
pointerEvents: disabled ? "none" : "auto",
}}
onPointerDown={(event) =>
handleAssetPointerDown(event, assetKey)
}
>
<span className="absolute left-0.5 top-0.5 text-[8px] font-medium text-gray-700 dark:text-gray-200 bg-white/80 dark:bg-gray-900/80 px-1 rounded pointer-events-none">
{assetLabels[assetKey]}
</span>
</div>
);
})}
</div>
</div>
<div
className={
isPreviewLayout
? "shrink-0 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
: "space-y-3"
}
>
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">
{t("template.selectedAsset", "Selected asset")}
</p>
<select
disabled={disabled}
value={selectedKey}
onChange={(e) =>
setSelectedKey(e.target.value as LetterAbsoluteAssetKey)
}
className="w-full px-2 py-1.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
>
{LETTER_PLACEMENT_CANVAS_ASSET_KEYS.map((key) => (
<option key={key} value={key}>
{assetLabels[key]}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">
{t("template.positionTop", "Top (px)")}
</label>
<input
type="number"
step={1}
disabled={disabled}
value={selectedTop}
onChange={(e) => updateSelectedCoord("top", e.target.value)}
className="w-full px-2 py-1.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">
{t("template.positionLeft", "Left (px)")}
</label>
<input
type="number"
step={1}
disabled={disabled}
value={selectedLeft}
onChange={(e) => updateSelectedCoord("left", e.target.value)}
className="w-full px-2 py-1.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">
{t("template.layerOrder", "Layer order (back → front)")}
</p>
<ul className="space-y-1">
{layerOrder.map((key, index) => (
<li
key={key}
className={`flex items-center justify-between gap-1 px-2 py-1 rounded border text-xs ${
selectedKey === key
? "border-purple-400 bg-purple-50 dark:bg-purple-900/20"
: "border-gray-200 dark:border-gray-600"
}`}
>
<button
type="button"
disabled={disabled}
onClick={() => setSelectedKey(key)}
className="text-left flex-1 truncate text-gray-700 dark:text-gray-200"
>
{assetLabels[key]}
</button>
<div className="flex gap-0.5 shrink-0">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => moveLayer(index, -1)}
className="px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-500 disabled:opacity-40"
title={t("template.sendBackward", "Send backward")}
>
</button>
<button
type="button"
disabled={disabled || index === layerOrder.length - 1}
onClick={() => moveLayer(index, 1)}
className="px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-500 disabled:opacity-40"
title={t("template.bringForward", "Bring forward")}
>
</button>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
LETTER_LABEL_DISPLAY_NAMES,
type LetterLabelEntry,
type LetterLabelKey,
type LetterLabelStyle,
normalizeLabelStyle,
} from "../utils/letterLabelsConstants";
import {
StyleSettingCard,
STYLE_INPUT_CLASS,
} from "./styleSettingsUi";
import { cn } from "@/shared/lib/utils";
interface LetterLabelFieldEditorProps {
labelKey: LetterLabelKey;
entry: LetterLabelEntry;
isDefault: boolean;
showDefaultToggle?: boolean;
onToggleDefault?: () => void;
onChange: (entry: LetterLabelEntry) => void;
}
const isUnderlined = (style?: LetterLabelStyle) =>
!!style && (style["text-decoration"] || "").includes("underline");
const isBold = (style?: LetterLabelStyle) =>
!!style &&
["bold", "600", "700", "800", "900"].includes(style["font-weight"] || "");
export const LetterLabelFieldEditor: React.FC<LetterLabelFieldEditorProps> = ({
labelKey,
entry,
isDefault,
showDefaultToggle = false,
onToggleDefault,
onChange,
}) => {
const { t, i18n } = useTranslation();
const displayLang = i18n.language?.startsWith("am") ? "am" : "en";
const fieldTitle = LETTER_LABEL_DISPLAY_NAMES[labelKey][displayLang];
const handleTextChange = (lang: "en" | "am", text: string) => {
onChange({ ...entry, [lang]: text });
};
const handleStyleChange = (
patch: LetterLabelStyle,
removals: string[] = [],
) => {
const merged: LetterLabelStyle = { ...(entry.style ?? {}), ...patch };
for (const prop of removals) delete merged[prop];
const cleaned = normalizeLabelStyle(merged);
const nextEntry = { ...entry };
if (cleaned) nextEntry.style = cleaned;
else delete nextEntry.style;
onChange(nextEntry);
};
return (
<StyleSettingCard
title={fieldTitle}
subtitle={t("template.letterLabelHint", "Label text shown on the letter")}
isDefault={isDefault}
showCustomBadge={!showDefaultToggle}
onToggleDefault={showDefaultToggle ? onToggleDefault : undefined}
className="mt-3"
>
<fieldset disabled={isDefault} className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{t("common.english", "English")}
</label>
<input
type="text"
value={entry.en}
onChange={(e) => handleTextChange("en", e.target.value)}
className={cn(STYLE_INPUT_CLASS, "px-2 py-1")}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{t("common.amharic", "Amharic")}
</label>
<input
type="text"
dir="auto"
value={entry.am}
onChange={(e) => handleTextChange("am", e.target.value)}
className={cn(STYLE_INPUT_CLASS, "px-2 py-1")}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={isUnderlined(entry.style)}
onChange={(e) =>
handleStyleChange(
e.target.checked ? { "text-decoration": "underline" } : {},
e.target.checked ? [] : ["text-decoration"],
)
}
/>
{t("template.underline", "Underline")}
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={isBold(entry.style)}
onChange={(e) =>
handleStyleChange(
e.target.checked ? { "font-weight": "bold" } : {},
e.target.checked ? [] : ["font-weight"],
)
}
/>
{t("template.bold", "Bold")}
</label>
</div>
</fieldset>
</StyleSettingCard>
);
};

View File

@@ -0,0 +1,240 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ImageIcon, Trash2 } from "lucide-react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Button } from "@/shared/common/ui/button";
import {
extractUrlFromListStyleImage,
isCustomListStyleImage,
isDisclosureListStyleImage,
ListBulletResource,
toResourceListStyleValue,
} from "../utils/templateResourceConstants";
import {
STYLE_SELECT_CONTENT_CLASS,
STYLE_SELECT_TRIGGER_CLASS,
} from "./styleSettingsUi";
interface ListStyleImageFieldProps {
listStyleImage?: string;
listStyle?: string;
resourceId?: string | null;
bulletResources: ListBulletResource[];
bulletResourcesLoading: boolean;
locale: "am" | "en";
onReplace: (resourceId: string) => void;
onRemove: () => void;
onRefreshResources: () => Promise<unknown>;
}
const getResourceLabel = (
resource: ListBulletResource,
locale: "am" | "en",
) => resource.name?.[locale] || resource.name?.en || resource.id;
const DisclosurePreview = () => (
<div className="flex h-full w-full items-center justify-center text-gray-700 dark:text-gray-200">
<span className="text-lg leading-none" aria-hidden>
</span>
</div>
);
export const ListStyleImageField: React.FC<ListStyleImageFieldProps> = ({
listStyleImage,
listStyle,
resourceId,
bulletResources,
bulletResourcesLoading,
locale,
onReplace,
onRemove,
onRefreshResources,
}) => {
const { t } = useTranslation();
const [imageError, setImageError] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const matchedResource = useMemo(() => {
if (resourceId) {
return bulletResources.find((resource) => resource.id === resourceId) ?? null;
}
if (!isCustomListStyleImage(listStyleImage)) return null;
return (
bulletResources.find(
(resource) =>
listStyleImage?.includes(resource.id) ||
listStyleImage?.includes(resource.presigned) ||
Boolean(
resource.fileInfo?.fileName &&
listStyleImage?.includes(resource.fileInfo.fileName),
),
) ?? null
);
}, [bulletResources, listStyleImage, resourceId]);
const previewSrc = useMemo(() => {
if (matchedResource?.presigned) {
return matchedResource.presigned;
}
if (isDisclosureListStyleImage(listStyleImage)) {
return extractUrlFromListStyleImage(listStyleImage);
}
if (listStyle?.split(" ")[0] === "disclosure-closed") {
return extractUrlFromListStyleImage(listStyleImage) ?? null;
}
if (isCustomListStyleImage(listStyleImage)) {
return extractUrlFromListStyleImage(listStyleImage);
}
return null;
}, [listStyle, listStyleImage, matchedResource]);
const hasImage = Boolean(
matchedResource ||
isCustomListStyleImage(listStyleImage) ||
isDisclosureListStyleImage(listStyleImage) ||
listStyle?.split(" ")[0] === "disclosure-closed",
);
useEffect(() => {
setImageError(false);
}, [previewSrc, matchedResource?.presigned]);
const handleImageError = useCallback(async () => {
if (isRefreshing || bulletResourcesLoading) return;
setImageError(true);
setIsRefreshing(true);
try {
await onRefreshResources();
} finally {
setIsRefreshing(false);
}
}, [bulletResourcesLoading, isRefreshing, onRefreshResources]);
const selectedResourceValue = matchedResource
? toResourceListStyleValue(matchedResource.id)
: undefined;
return (
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/80 p-3 dark:border-gray-600 dark:bg-gray-900/30">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300">
{t("template.listStyleImage", "List Style Image")}
</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div
className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-600 dark:bg-gray-800"
aria-label={t("template.listStyleImagePreview", "List style image preview")}
>
{previewSrc && !imageError ? (
isDisclosureListStyleImage(listStyleImage) ||
listStyle?.split(" ")[0] === "disclosure-closed" ? (
<DisclosurePreview />
) : (
<img
src={previewSrc}
alt=""
className="h-full w-full object-contain p-1"
onError={handleImageError}
/>
)
) : imageError ? (
<div className="px-2 text-center text-[10px] text-gray-500 dark:text-gray-400">
{isRefreshing
? t("template.refreshingImage", "Refreshing...")
: t("template.imageUnavailable", "Image unavailable")}
</div>
) : (
<div className="flex flex-col items-center gap-1 px-2 text-gray-400 dark:text-gray-500">
<ImageIcon className="h-5 w-5" aria-hidden />
<span className="text-[10px] text-center">
{t("template.noListStyleImage", "No image")}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
{matchedResource && (
<p className="text-xs text-gray-600 dark:text-gray-300">
{getResourceLabel(matchedResource, locale)}
</p>
)}
<Select
value={selectedResourceValue}
onValueChange={(value) => {
const nextResourceId = value.slice("resource:".length);
onReplace(nextResourceId);
}}
disabled={bulletResourcesLoading || bulletResources.length === 0}
>
<SelectTrigger className={STYLE_SELECT_TRIGGER_CLASS}>
<SelectValue
placeholder={t(
"template.replaceListStyleImage",
"Replace image",
)}
/>
</SelectTrigger>
<SelectContent className={STYLE_SELECT_CONTENT_CLASS}>
<SelectGroup>
<SelectLabel>
{t(
"template.customListBullets",
"Custom bullets (Resource Configuration)",
)}
</SelectLabel>
{bulletResources.map((resource) => (
<SelectItem
key={resource.id}
value={toResourceListStyleValue(resource.id)}
>
<span className="flex min-w-0 items-center gap-2">
<img
src={resource.presigned}
alt=""
className="h-4 w-4 shrink-0 object-contain"
/>
<span className="truncate">
{getResourceLabel(resource, locale)}
</span>
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{hasImage && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={onRemove}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{t("template.removeListStyleImage", "Remove image")}
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,111 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
StyleSettingCard,
STYLE_INPUT_CLASS,
} from "./styleSettingsUi";
export type PageMarginValue = {
top: number;
left: number;
right: number;
bottom: number;
};
export const PAGE_MARGIN_SETTING_CODE = "letter-page-margin";
export const DEFAULT_PAGE_MARGIN: PageMarginValue = {
top: 90,
left: 30,
right: 93,
bottom: 30,
};
export const isPageMarginSetting = (code: string): boolean =>
code === PAGE_MARGIN_SETTING_CODE;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const toMargin = (value: unknown, fallback: number): number => {
const n =
typeof value === "number"
? value
: typeof value === "string"
? parseFloat(value)
: NaN;
return Number.isFinite(n) && n >= 0 ? n : fallback;
};
/** Coerces any input to a valid PageMarginValue, falling back to defaults. */
export const normalizePageMarginValue = (value: unknown): PageMarginValue => {
const input = isPlainObject(value) ? value : {};
return {
top: toMargin(input.top, DEFAULT_PAGE_MARGIN.top),
left: toMargin(input.left, DEFAULT_PAGE_MARGIN.left),
right: toMargin(input.right, DEFAULT_PAGE_MARGIN.right),
bottom: toMargin(input.bottom, DEFAULT_PAGE_MARGIN.bottom),
};
};
interface PageMarginEditorProps {
value: unknown;
isDefault: boolean;
onChange: (margins: PageMarginValue) => void;
onToggleDefault: () => void;
}
const MARGIN_FIELDS: Array<{ key: keyof PageMarginValue; label: string }> = [
{ key: "top", label: "Top" },
{ key: "right", label: "Right" },
{ key: "bottom", label: "Bottom" },
{ key: "left", label: "Left" },
];
export const PageMarginEditor: React.FC<PageMarginEditorProps> = ({
value,
isDefault,
onChange,
onToggleDefault,
}) => {
const { t } = useTranslation();
const margins = normalizePageMarginValue(value);
const handleMarginChange = (key: keyof PageMarginValue, raw: string) => {
const next = raw === "" ? 0 : Math.max(0, Number(raw));
if (!Number.isFinite(next)) return;
onChange({ ...margins, [key]: next });
};
return (
<StyleSettingCard
title={t("template.pageMargin", "Page Margins")}
subtitle={t(
"template.pageMarginHint",
"Set top, right, bottom, and left margins in pixels.",
)}
code={PAGE_MARGIN_SETTING_CODE}
isDefault={isDefault}
onToggleDefault={onToggleDefault}
>
<fieldset disabled={isDefault}>
<div className="grid grid-cols-2 gap-3">
{MARGIN_FIELDS.map(({ key, label }) => (
<div key={key}>
<label className="mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300">
{t(`template.margin${label}`, label)} (px)
</label>
<input
type="number"
min={0}
value={margins[key]}
onChange={(e) => handleMarginChange(key, e.target.value)}
className={STYLE_INPUT_CLASS}
/>
</div>
))}
</div>
</fieldset>
</StyleSettingCard>
);
};

View File

@@ -0,0 +1,74 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2, RotateCcw, X } from "lucide-react";
interface ResetAllDefaultsModalProps {
isOpen: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const ResetAllDefaultsModal: React.FC<ResetAllDefaultsModalProps> = ({
isOpen,
busy = false,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md overflow-hidden rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("template.resetAllTitle", "Reset all settings?")}
</h3>
</div>
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-lg p-1 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:hover:bg-gray-700"
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
<div className="p-6">
<p className="text-sm text-gray-700 dark:text-gray-300">
{t(
"template.resetAllDescription",
"This will restore every configurable style setting for this unit to the global template defaults. Unit overrides will be removed and saved immediately. Preview preferences (such as language) are not affected.",
)}
</p>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("common.cancel", "Cancel")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50 dark:hover:bg-amber-500"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("template.resetAllConfirm", "Reset to defaults")}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,167 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { History, Loader, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { TemplateService } from "@/user-management/services/api/templateService";
import {
TemplateSampleSetting,
TemplateSettingValueHistoryEntry,
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
type SettingHistoryModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
setting: TemplateSampleSetting | null;
unitId?: string;
onRestored: () => void | Promise<void>;
};
const formatTimestamp = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
export const SettingHistoryModal: React.FC<SettingHistoryModalProps> = ({
open,
onOpenChange,
setting,
unitId,
onRestored,
}) => {
const { t } = useTranslation();
const [items, setItems] = useState<TemplateSettingValueHistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async () => {
if (!setting?.id || !unitId) return;
try {
setLoading(true);
setError(null);
const response = await TemplateService.getTemplateSettingValueHistory(
setting.id,
unitId,
);
setItems(response.items ?? []);
} catch (err) {
const message =
err instanceof Error
? err.message
: t("template.historyLoadFailed", "Failed to load setting history");
setError(message);
setItems([]);
} finally {
setLoading(false);
}
}, [setting?.id, unitId, t]);
useEffect(() => {
if (open) {
void loadHistory();
} else {
setItems([]);
setError(null);
setRestoringId(null);
}
}, [open, loadHistory]);
const handleRestore = async (historyId: string) => {
if (!setting?.id || !unitId) return;
try {
setRestoringId(historyId);
await TemplateService.restoreTemplateSettingFromHistory(
setting.id,
historyId,
unitId,
);
toast.success(
t("template.historyRestored", "Setting restored from history"),
);
await onRestored();
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error
? err.message
: t("template.historyRestoreFailed", "Failed to restore setting");
toast.error(message);
} finally {
setRestoringId(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5 text-purple-600" />
{t("template.settingHistory", "Setting History")}
</DialogTitle>
</DialogHeader>
{setting ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
{setting.code}
</p>
) : null}
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader className="h-6 w-6 animate-spin text-purple-600" />
</div>
) : error ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t("template.noHistory", "No history entries for this setting.")}
</p>
) : (
<div className="max-h-[50vh] space-y-3 overflow-y-auto pr-1">
{items.map((entry) => (
<div
key={entry.id}
className="rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-gray-600 dark:text-gray-300">
{formatTimestamp(entry.createdAt || entry.updatedAt)}
</div>
<button
type="button"
onClick={() => void handleRestore(entry.id)}
disabled={restoringId === entry.id}
className="inline-flex items-center gap-1 rounded-md border border-purple-200 px-2.5 py-1 text-xs font-medium text-purple-700 transition-colors hover:bg-purple-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-purple-800 dark:text-purple-300 dark:hover:bg-purple-900/30"
>
{restoringId === entry.id ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
{t("template.restoreVersion", "Restore")}
</button>
</div>
<pre className="overflow-x-auto rounded-md bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-200">
{JSON.stringify(entry.value ?? {}, null, 2)}
</pre>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,252 @@
import React, { useState, useMemo } from "react";
import { Search, ChevronDown, Loader } from "lucide-react";
import { TemplateSampleSetting } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { useTranslation } from "react-i18next";
interface SectionSettings {
items: TemplateSampleSetting[];
page: number;
hasMore: boolean;
loading: boolean;
}
interface SettingsPanelProps {
settings: TemplateSampleSetting[];
onSettingChange: (index: number, field: string, value: string) => void;
onLoadMore?: (sectionKey: string, nextPage: number) => void;
}
interface StyleInputsProps {
setting: TemplateSampleSetting;
index: number;
onChange: (index: number, field: string, value: string) => void;
}
const StyleInputs: React.FC<StyleInputsProps> = ({
setting,
index,
onChange,
}) => {
return (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 space-y-3">
<h4 className="font-medium text-sm text-gray-900 dark:text-gray-100 capitalize">
{setting.code.replace("-", " ")}
</h4>
<div className="grid grid-cols-3 gap-3">
{Object.entries(setting.value).map(([key, value]) => (
<div key={key}>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1 capitalize">
{key.replace("-", " ")}
</label>
<input
type="text"
value={typeof value === "string" ? value : String(value ?? "")}
onChange={(e) => onChange(index, key, e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-purple-500 dark:bg-gray-600 dark:text-gray-200"
placeholder={key}
/>
</div>
))}
</div>
</div>
);
};
interface CollapsibleSectionProps {
title: string;
sectionKey: string;
sectionData: SectionSettings;
settings: TemplateSampleSetting[];
onSettingChange: (index: number, field: string, value: string) => void;
onLoadMore: (sectionKey: string, nextPage: number) => void;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
sectionKey,
sectionData,
settings,
onSettingChange,
onLoadMore,
}) => {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center justify-between transition-colors"
>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
<ChevronDown
className={`h-5 w-5 text-gray-600 dark:text-gray-300 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{isOpen && (
<div className="p-4 space-y-4">
{sectionData.items.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No settings available</p>
) : (
<>
{sectionData.items.map((setting) => {
const originalIndex = settings.findIndex(
(s) => s.code === setting.code,
);
return (
<StyleInputs
key={setting.code}
setting={setting}
index={originalIndex}
onChange={onSettingChange}
/>
);
})}
{sectionData.hasMore && (
<button
onClick={() =>
onLoadMore(sectionKey, sectionData.page + 1)
}
disabled={sectionData.loading}
className="w-full px-3 py-2 text-sm text-purple-600 hover:text-purple-800 hover:bg-purple-50 rounded transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{sectionData.loading ? (
<>
<Loader className="h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Load more"
)}
</button>
)}
</>
)}
</div>
)}
</div>
);
};
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
settings,
onSettingChange,
onLoadMore = () => {},
}) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
// Flatten all settings for search
const allSettings = useMemo(() => settings, [settings]);
// Filter settings based on search term
const filteredSettings = useMemo(() => {
if (!searchTerm.trim()) return null;
return allSettings.filter(
(s) =>
s.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
Object.values(s.value).some((v) =>
String(v).toLowerCase().includes(searchTerm.toLowerCase()),
),
);
}, [searchTerm, allSettings]);
// Group settings by section (for non-search view)
const sectionSettings: Record<string, SectionSettings> = useMemo(() => {
return {
subject: {
items: allSettings.filter((s) => s.code.includes("subject")),
page: 0,
hasMore: false,
loading: false,
},
body: {
items: allSettings.filter((s) => s.code.includes("body")),
page: 0,
hasMore: false,
loading: false,
},
header: {
items: allSettings.filter((s) => s.code.includes("header")),
page: 0,
hasMore: false,
loading: false,
},
footer: {
items: allSettings.filter((s) => s.code.includes("footer")),
page: 0,
hasMore: false,
loading: false,
},
};
}, [allSettings]);
return (
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder={t("common.search", "Search settings...")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Filtered Results View */}
{filteredSettings !== null ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Found {filteredSettings.length} setting
{filteredSettings.length !== 1 ? "s" : ""}
</p>
{filteredSettings.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">
{t("common.noResults", "No settings found")}
</p>
</div>
) : (
<div className="space-y-4">
{filteredSettings.map((setting) => {
const originalIndex = allSettings.findIndex(
(s) => s.code === setting.code,
);
return (
<StyleInputs
key={setting.code}
setting={setting}
index={originalIndex}
onChange={onSettingChange}
/>
);
})}
</div>
)}
</div>
) : (
/* Collapsible Sections View */
<div className="space-y-4">
{Object.entries(sectionSettings).map(([key, section]) => (
<CollapsibleSection
key={key}
title={key.charAt(0).toUpperCase() + key.slice(1)}
sectionKey={key}
sectionData={section}
settings={allSettings}
onSettingChange={onSettingChange}
onLoadMore={onLoadMore}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
interface StyleNavigationMenuProps {
categories: Array<{
code: string;
label: string;
settingIds: string[];
}>;
selectedCode: string | null;
onSelectCategory: (code: string) => void;
}
export const StyleNavigationMenu: React.FC<StyleNavigationMenuProps> = ({
categories,
selectedCode,
onSelectCategory,
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const selectedCategory = categories.find((cat) => cat.code === selectedCode);
return (
<div className="relative mb-4">
{/* Dropdown Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{selectedCategory
? selectedCategory.label
: t("template.selectStyleCategory", "Select Style Category")}
</span>
<ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{categories.map((category) => (
<button
key={category.code}
onClick={() => {
onSelectCategory(category.code);
setIsOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
selectedCode === category.code
? "bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium"
: "text-gray-700 dark:text-gray-300"
}`}
>
<div className="flex items-center justify-between">
<span>{category.label}</span>
<span className="text-xs text-gray-500">
{category.settingIds.length} {category.settingIds.length === 1 ? "setting" : "settings"}
</span>
</div>
</button>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,864 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from "react";
import { CSSBuilder } from "react-css-nocode-editor";
import { TemplateSampleSetting } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useListBulletResources } from "../hooks/useListBulletResources";
import {
isCustomListStyleImage,
isResourceListStyle,
ListBulletResource,
toResourceListStyleValue,
} from "../utils/templateResourceConstants";
import { extractListStyleResourceId } from "../utils/templateSamplePayloadUtils";
import {
mergeCssProperties,
omitCssProperties,
} from "../utils/cssConversion";
import {
getDefaultLetterFontFamily,
getDefaultLetterFontSize,
settingSupportsListFormatting,
} from "../utils/templateSettingConstants";
import { ListStyleImageField } from "./ListStyleImageField";
import { cn } from "@/shared/lib/utils";
import {
StyleSettingSelect,
StyleSettingToggle,
STYLE_INPUT_CLASS,
STYLE_SELECT_CONTENT_CLASS,
STYLE_SELECT_TRIGGER_CLASS,
} from "./styleSettingsUi";
const FONT_WEIGHT_OPTIONS = [
{ value: "normal", label: "Normal (400)" },
{ value: "500", label: "Medium (500)" },
{ value: "600", label: "Semi-bold (600)" },
{ value: "bold", label: "Bold (700)" },
{ value: "800", label: "Extra Bold (800)" },
{ value: "900", label: "Black (900)" },
];
const FONT_FAMILY_OPTIONS = [
{ value: "serif", label: "Serif" },
{ value: "sans-serif", label: "Sans-serif" },
{ value: "monospace", label: "Monospace" },
{ value: "cursive", label: "Cursive" },
{ value: "fantasy", label: "Fantasy" },
{ value: "Georgia, serif", label: "Georgia" },
{
value: "'Visual Geez Unicode', 'Visual Geez', serif",
label: "Visual Geez",
},
{
value: "'Times New Roman', Times, serif",
label: "Times New Roman",
},
{ value: "Arial, sans-serif", label: "Arial" },
{ value: "Verdana, sans-serif", label: "Verdana" },
{ value: "'Courier New', monospace", label: "Courier New" },
];
const TEXT_DECORATION_OPTIONS = [
{ value: "none", label: "None" },
{ value: "underline", label: "Underline" },
{ value: "overline", label: "Overline" },
{ value: "line-through", label: "Line Through" },
{ value: "underline overline", label: "Underline & Overline" },
];
const TEXT_ALIGN_OPTIONS = [
{ value: "Empty", label: "Empty" },
{ value: "left", label: "Left" },
{ value: "center", label: "Center" },
{ value: "right", label: "Right" },
{ value: "justify", label: "Justify" },
];
interface StyleSettingsEditorProps {
setting: TemplateSampleSetting;
index: number;
onChange: (index: number, cssString: string) => void;
isDefault: boolean;
onToggleDefault: (settingId: string) => void;
storedResourceId?: string | null;
onListStyleResourceChange?: (resourceId: string | null) => void;
previewLanguage?: "am" | "en";
}
const parseFontSizeParts = (
value: string | undefined,
fallback: string,
): { amount: string; unit: "px" | "pt" } => {
const resolved = value || fallback;
const match = resolved.match(/^([\d.]+)(px|pt)$/);
return {
amount: match?.[1] ?? "12",
unit: match?.[2] === "px" ? "px" : "pt",
};
};
const formatSettingTitle = (code: string) =>
code
.replace(/^letter-/, "")
.replace(/-style$/, "")
.replace(/-/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
// Generate SVG data URI for disclosure-closed
const getDisclosureSVG = () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><polygon points="6,4 12,8 6,12" fill="currentColor"/></svg>`;
return `url('data:image/svg+xml;base64,${btoa(svg)}')`;
};
// CSS parser (handles data URIs)
const parseCSSString = (cssString: string): Record<string, string> => {
const styles: Record<string, string> = {};
let depth = 0;
let current = '';
const processDeclaration = (decl: string) => {
decl = decl.trim();
if (!decl) return;
const colonIdx = decl.indexOf(':');
if (colonIdx <= 0) return;
const property = decl.slice(0, colonIdx).trim();
const value = decl.slice(colonIdx + 1).trim();
if (property && value) styles[property] = value;
};
for (let i = 0; i < cssString.length; i++) {
const ch = cssString[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ';' && depth === 0) {
processDeclaration(current);
current = '';
} else {
current += ch;
}
}
processDeclaration(current);
return styles;
};
// Build CSS string from object
const buildCSSString = (styles: Record<string, string>): string => {
return Object.entries(styles)
.map(([key, value]) => `${key}: ${value};`)
.join(' ');
};
const normalizeStyles = (
styles: Record<string, string>,
bulletResources: ListBulletResource[] = [],
): Record<string, string> => {
const normalized = { ...styles };
const listStyle = normalized["list-style"]?.split(" ")[0];
if (listStyle === "disclosure-closed") {
normalized["list-style-image"] = getDisclosureSVG();
delete normalized["list-style-type"];
return normalized;
}
if (listStyle && isResourceListStyle(listStyle)) {
const resourceId = listStyle.slice("resource:".length);
const resource = bulletResources.find((item) => item.id === resourceId);
normalized["list-style"] = "none";
delete normalized["list-style-type"];
if (resource) {
normalized["list-style-image"] = `url('${resource.presigned}')`;
}
return normalized;
}
if (isCustomListStyleImage(normalized["list-style-image"])) {
normalized["list-style"] = normalized["list-style"] || "none";
delete normalized["list-style-type"];
return normalized;
}
delete normalized["list-style-image"];
return normalized;
};
const resolveListStyleSelectValue = (
styles: Record<string, string>,
bulletResources: ListBulletResource[],
): string => {
const listStyle = styles["list-style"]?.split(" ")[0];
if (listStyle && isResourceListStyle(listStyle)) {
return listStyle;
}
const imageValue = styles["list-style-image"];
if (imageValue) {
const matchedResource = bulletResources.find(
(resource) =>
imageValue.includes(resource.presigned) ||
imageValue.includes(resource.id),
);
if (matchedResource) {
return toResourceListStyleValue(matchedResource.id);
}
}
return listStyle || styles["list-style-type"] || "disc";
};
const STANDARD_LIST_STYLE_OPTIONS = [
{ value: "disc", label: "Bullet" },
{ value: "circle", label: "Circle" },
{ value: "square", label: "Square" },
{ value: "disclosure-closed", label: "Black Right Triangle (▶)" },
{ value: "decimal", label: "Numbers (1, 2, 3)" },
{ value: "lower-alpha", label: "Lowercase Letters (a, b, c)" },
{ value: "upper-alpha", label: "Uppercase Letters (A, B, C)" },
{ value: "lower-roman", label: "Lowercase Roman (i, ii, iii)" },
{ value: "upper-roman", label: "Uppercase Roman (I, II, III)" },
{ value: "none", label: "None" },
] as const;
const getResourceLabel = (
resource: ListBulletResource,
locale: "am" | "en",
) => resource.name?.[locale] || resource.name?.en || resource.id;
const ListStyleResourcePreview = ({
resource,
locale,
}: {
resource: ListBulletResource;
locale: "am" | "en";
}) => (
<span className="flex min-w-0 items-center gap-2">
<img
src={resource.presigned}
alt=""
className="h-4 w-4 shrink-0 object-contain"
/>
<span className="truncate">{getResourceLabel(resource, locale)}</span>
</span>
);
export const StyleSettingsEditor: React.FC<StyleSettingsEditorProps> = ({
setting,
index,
onChange,
isDefault,
onToggleDefault,
storedResourceId,
onListStyleResourceChange,
previewLanguage = "am",
}) => {
const { t, i18n } = useTranslation();
const defaultFontFamily = getDefaultLetterFontFamily(previewLanguage);
const defaultFontSize = getDefaultLetterFontSize();
const [showAdvanced, setShowAdvanced] = useState(false);
const [isExpanded, setIsExpanded] = useState(() => !isDefault);
const { resources: bulletResources, loading: bulletResourcesLoading, refetch: refetchBulletResources } =
useListBulletResources();
const locale = i18n.language?.startsWith("am") ? "am" : "en";
const syncedResourceIdRef = useRef<string | null | undefined>(undefined);
const hasInitialNormalizedRef = useRef(false);
const hydratedResourceRef = useRef<string | null>(null);
useEffect(() => {
if (!isDefault) {
setIsExpanded(true);
}
}, [isDefault]);
// Get the CSS string
const cssString = useMemo(() => {
return typeof setting.value === "string" ? setting.value : "";
}, [setting.value]);
// Parse CSS string to object
const parsedStyles = useMemo(() => {
return parseCSSString(cssString);
}, [cssString]);
const fontSizeParts = useMemo(
() => parseFontSizeParts(parsedStyles["font-size"], defaultFontSize),
[parsedStyles, defaultFontSize],
);
const supportsListFormatting = useMemo(
() => settingSupportsListFormatting(setting),
[setting],
);
const advancedCssString = useMemo(
() => omitCssProperties(cssString, ["list-style-image"]),
[cssString],
);
// Detect if this is a header/footer style
const isHeaderFooterStyle = useMemo(() => {
const code = setting.code.toLowerCase();
return code.includes("header") || code.includes("footer");
}, [setting.code]);
// Normalization helper
const listStyleSelectValue = useMemo(
() => resolveListStyleSelectValue(parsedStyles, bulletResources),
[parsedStyles, bulletResources],
);
const selectedBulletResource = useMemo(() => {
if (!isResourceListStyle(listStyleSelectValue)) return null;
const resourceId = listStyleSelectValue.slice("resource:".length);
return bulletResources.find((resource) => resource.id === resourceId) ?? null;
}, [listStyleSelectValue, bulletResources]);
const selectedStandardListStyle = useMemo(
() =>
STANDARD_LIST_STYLE_OPTIONS.find(
(option) => option.value === listStyleSelectValue,
) ?? null,
[listStyleSelectValue],
);
// Handle property change with special list-style-image logic
const handlePropertyChange = useCallback(
(property: string, value: string) => {
let updated = { ...parsedStyles, [property]: value };
if (property === "list-style") {
const specialListStyles = [
"disclosure-closed",
"disclosure-open",
"none",
];
if (isResourceListStyle(value)) {
const resourceId = value.slice("resource:".length);
const resource = bulletResources.find((item) => item.id === resourceId);
updated["list-style"] = "none";
delete updated["list-style-type"];
if (resource) {
updated["list-style-image"] = `url('${resource.presigned}')`;
}
if (syncedResourceIdRef.current !== resourceId) {
syncedResourceIdRef.current = resourceId;
onListStyleResourceChange?.(resourceId);
}
} else if (specialListStyles.includes(value)) {
delete updated["list-style-type"];
if (value !== "disclosure-closed") {
delete updated["list-style-image"];
}
if (syncedResourceIdRef.current !== null) {
syncedResourceIdRef.current = null;
onListStyleResourceChange?.(null);
}
} else {
delete updated["list-style-image"];
if (syncedResourceIdRef.current !== null) {
syncedResourceIdRef.current = null;
onListStyleResourceChange?.(null);
}
}
}
updated = normalizeStyles(updated, bulletResources);
const newCssString = buildCSSString(updated);
onChange(index, newCssString);
},
[parsedStyles, index, onChange, bulletResources, onListStyleResourceChange],
);
// Sync resourceId from existing CSS once bullet resources are available.
// Do not call onChange here — that caused infinite update loops when presigned URLs changed.
useEffect(() => {
if (bulletResourcesLoading) return;
const resourceId = extractListStyleResourceId(
normalizeStyles(parseCSSString(cssString), bulletResources),
bulletResources,
);
if (syncedResourceIdRef.current === resourceId) return;
syncedResourceIdRef.current = resourceId;
onListStyleResourceChange?.(resourceId);
}, [
bulletResourcesLoading,
bulletResources,
cssString,
onListStyleResourceChange,
]);
// Hydrate custom-image from stored resourceId when API returns type only.
useEffect(() => {
if (bulletResourcesLoading || !storedResourceId) return;
if (hydratedResourceRef.current === storedResourceId) return;
const current = parseCSSString(cssString);
if (isCustomListStyleImage(current["list-style-image"])) {
hydratedResourceRef.current = storedResourceId;
return;
}
const resource = bulletResources.find(
(item) => item.id === storedResourceId,
);
if (!resource) return;
hydratedResourceRef.current = storedResourceId;
const hydrated = normalizeStyles(
{
...current,
"list-style": "none",
"list-style-image": `url('${resource.presigned}')`,
},
bulletResources,
);
const hydratedCss = buildCSSString(hydrated);
if (hydratedCss !== cssString) {
onChange(index, hydratedCss);
}
}, [
bulletResourcesLoading,
storedResourceId,
bulletResources,
cssString,
index,
onChange,
]);
// One-time normalization on mount (e.g. disclosure-closed SVG).
useEffect(() => {
if (hasInitialNormalizedRef.current) return;
hasInitialNormalizedRef.current = true;
const current = parseCSSString(cssString);
const normalized = normalizeStyles(current, bulletResources);
const normalizedCss = buildCSSString(normalized);
if (normalizedCss !== cssString) {
onChange(index, normalizedCss);
}
}, []);
const handleAdvancedChange = useCallback(
(newCssString: string) => {
const preservedListStyleImage = parsedStyles["list-style-image"];
const mergedCssString = preservedListStyleImage
? mergeCssProperties(newCssString, {
"list-style-image": preservedListStyleImage,
})
: newCssString;
const normalized = normalizeStyles(
parseCSSString(mergedCssString),
bulletResources,
);
const normalizedCss = buildCSSString(normalized);
if (normalizedCss === cssString) return;
const resourceId = extractListStyleResourceId(normalized, bulletResources);
if (syncedResourceIdRef.current !== resourceId) {
syncedResourceIdRef.current = resourceId;
onListStyleResourceChange?.(resourceId);
}
onChange(index, normalizedCss);
},
[
index,
onChange,
cssString,
parsedStyles,
bulletResources,
onListStyleResourceChange,
],
);
const handleReplaceListStyleImage = useCallback(
(resourceId: string) => {
handlePropertyChange("list-style", toResourceListStyleValue(resourceId));
},
[handlePropertyChange],
);
const handleRemoveListStyleImage = useCallback(() => {
const updated = { ...parsedStyles };
delete updated["list-style-image"];
const currentListStyle = updated["list-style"]?.split(" ")[0];
if (!currentListStyle || currentListStyle === "none") {
updated["list-style"] = "disc";
}
if (syncedResourceIdRef.current !== null) {
syncedResourceIdRef.current = null;
onListStyleResourceChange?.(null);
}
const normalized = normalizeStyles(updated, bulletResources);
onChange(index, buildCSSString(normalized));
}, [
parsedStyles,
bulletResources,
index,
onChange,
onListStyleResourceChange,
]);
if (!setting || !setting.code || !setting.id) {
return null;
}
return (
<div
className={cn(
"mt-4 overflow-hidden rounded-xl border bg-white shadow-sm transition-shadow dark:bg-gray-800",
isDefault
? "border-gray-200 dark:border-gray-700"
: "border-purple-200 ring-1 ring-purple-100 dark:border-purple-800 dark:ring-purple-900/40",
)}
>
<button
type="button"
onClick={() => setIsExpanded((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-750"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h4 className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatSettingTitle(setting.code)}
</h4>
<span className="rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{setting.code}
</span>
{!isDefault && (
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
{t("template.customized", "Customized")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
role="presentation"
>
<StyleSettingToggle
isDefault={isDefault}
onToggle={() => onToggleDefault(setting.id)}
/>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-gray-400 transition-transform",
isExpanded && "rotate-180",
)}
/>
</div>
</button>
{isExpanded && (
<div className="space-y-4 border-t border-gray-100 px-4 py-4 dark:border-gray-700">
{/* Common Properties */}
<div className="space-y-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
{t("template.commonProperties", "Common Properties")}
</p>
{isHeaderFooterStyle ? (
<>
{/* Header/Footer Style Properties */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.width", "Width")}
</label>
<input
type="text"
value={parsedStyles["width"] || "100%"}
onChange={(e) => handlePropertyChange("width", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.height", "Height")}
</label>
<input
type="text"
value={parsedStyles["height"] || "auto"}
onChange={(e) => handlePropertyChange("height", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
/>
</div>
{/* Margin */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.margin", "Margin")}
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Top</label>
<input
type="text"
value={parsedStyles["margin-top"] || "0"}
onChange={(e) => handlePropertyChange("margin-top", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Right</label>
<input
type="text"
value={parsedStyles["margin-right"] || "0"}
onChange={(e) => handlePropertyChange("margin-right", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Bottom</label>
<input
type="text"
value={parsedStyles["margin-bottom"] || "0"}
onChange={(e) => handlePropertyChange("margin-bottom", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Left</label>
<input
type="text"
value={parsedStyles["margin-left"] || "0"}
onChange={(e) => handlePropertyChange("margin-left", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
</div>
</div>
{/* Padding */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.padding", "Padding")}
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Top</label>
<input
type="text"
value={parsedStyles["padding-top"] || "0"}
onChange={(e) => handlePropertyChange("padding-top", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Right</label>
<input
type="text"
value={parsedStyles["padding-right"] || "0"}
onChange={(e) => handlePropertyChange("padding-right", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Bottom</label>
<input
type="text"
value={parsedStyles["padding-bottom"] || "0"}
onChange={(e) => handlePropertyChange("padding-bottom", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400">Left</label>
<input
type="text"
value={parsedStyles["padding-left"] || "0"}
onChange={(e) => handlePropertyChange("padding-left", e.target.value)}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"
/>
</div>
</div>
</div>
</>
) : (
<>
{/* Text Style Properties (with text-align) */}
{/* Font Size */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.fontSize", "Font Size")}
</label>
<div className="flex gap-2">
<input
type="number"
value={fontSizeParts.amount}
onChange={(e) =>
handlePropertyChange(
"font-size",
`${e.target.value}${fontSizeParts.unit}`,
)
}
placeholder="12"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
/>
<span className="flex items-center text-gray-600 dark:text-gray-400 text-sm">
{fontSizeParts.unit}
</span>
</div>
</div>
{/* Font Weight */}
<StyleSettingSelect
label={t("template.fontWeight", "Font Weight")}
value={parsedStyles["font-weight"] || "normal"}
onValueChange={(value) => handlePropertyChange("font-weight", value)}
options={FONT_WEIGHT_OPTIONS}
/>
{/* Font Family */}
<StyleSettingSelect
label={t("template.fontFamily", "Font Family")}
value={parsedStyles["font-family"] || defaultFontFamily}
onValueChange={(value) => handlePropertyChange("font-family", value)}
options={FONT_FAMILY_OPTIONS}
/>
{/* Text Decoration */}
<StyleSettingSelect
label={t("template.textDecoration", "Text Decoration")}
value={parsedStyles["text-decoration"] || "none"}
onValueChange={(value) =>
handlePropertyChange("text-decoration", value)
}
options={TEXT_DECORATION_OPTIONS}
/>
{/* Text Align */}
<StyleSettingSelect
label={t("template.textAlign", "Text Align")}
value={parsedStyles["text-align"] || "left"}
onValueChange={(value) => handlePropertyChange("text-align", value)}
options={TEXT_ALIGN_OPTIONS}
/>
{/* List Style */}
{supportsListFormatting && (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t("template.listStyle", "List Style")}
</label>
<Select
value={listStyleSelectValue}
onValueChange={(value) => handlePropertyChange("list-style", value)}
>
<SelectTrigger className={STYLE_SELECT_TRIGGER_CLASS}>
<SelectValue
placeholder={t("template.listStyle", "List Style")}
>
{selectedBulletResource ? (
<ListStyleResourcePreview
resource={selectedBulletResource}
locale={locale}
/>
) : (
selectedStandardListStyle?.label ?? listStyleSelectValue
)}
</SelectValue>
</SelectTrigger>
<SelectContent className={STYLE_SELECT_CONTENT_CLASS}>
{STANDARD_LIST_STYLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{bulletResources.length > 0 && (
<SelectGroup>
<SelectLabel>
{t(
"template.customListBullets",
"Custom bullets (Resource Configuration)",
)}
</SelectLabel>
{bulletResources.map((resource) => (
<SelectItem
key={resource.id}
value={toResourceListStyleValue(resource.id)}
>
<ListStyleResourcePreview
resource={resource}
locale={locale}
/>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
)}
</>
)}
</div>
{/* Advanced CSS Editor Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60"
>
<span>{t("template.advancedCSS", "Advanced CSS Editor")}</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
showAdvanced && "rotate-180",
)}
/>
</button>
{showAdvanced && (
<div className="space-y-2 border-t border-gray-100 pt-3 dark:border-gray-700">
{supportsListFormatting && (
<ListStyleImageField
listStyleImage={parsedStyles["list-style-image"]}
listStyle={parsedStyles["list-style"]}
resourceId={storedResourceId}
bulletResources={bulletResources}
bulletResourcesLoading={bulletResourcesLoading}
locale={locale}
onReplace={handleReplaceListStyleImage}
onRemove={handleRemoveListStyleImage}
onRefreshResources={refetchBulletResources}
/>
)}
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">
{t("template.customCSS", "Custom CSS")}
</p>
<CSSBuilder
key={`${setting.id}-${showAdvanced ? "open" : "closed"}`}
style={advancedCssString}
onChange={handleAdvancedChange}
reactive
/>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,60 @@
// components/UnsavedChangesModal.tsx
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
interface UnsavedChangesModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({
isOpen,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center gap-2 text-amber-600">
<AlertTriangle className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t("common.warning", "Warning")}</h3>
</div>
<button
onClick={onCancel}
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
<div className="p-6">
<p className="text-gray-700">
{t(
"template.unsavedChangesWarning",
"You have unsaved changes. Are you sure you want to leave?"
)}
</p>
</div>
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 bg-gray-50">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{t("common.cancel", "Cancel")}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
{t("common.leave", "Leave")}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,179 @@
import {
buildDefaultAbsoluteAnchors,
closingAssetFromCanvasTop,
closingAssetToCanvasTop,
computeLetterContentAreaPx,
DEFAULT_LETTER_PAGE_MARGINS_PX,
isClosingLayoutAnchor,
migrateClosingAssetTopFromPageAbsolute,
type LetterLanguage,
type LetterPageMarginsPx,
} from "../utils/letter-page-dimensions";
export type LayoutPositionValue = {
top?: number;
left?: number;
mode?: string;
offsetX?: number;
offsetY?: number;
};
export {
buildDefaultAbsoluteAnchors,
buildDefaultClosingAssetAnchors,
closingAssetFromCanvasTop,
closingAssetToCanvasTop,
computeLetterContentAreaPx,
DEFAULT_LETTER_PAGE_MARGINS_PX,
PDF_PAGE_HEIGHT_PX,
PDF_PAGE_WIDTH_PX,
type LetterLanguage,
} from "../utils/letter-page-dimensions";
export type LayoutPositionAnchor =
| "signatures"
| "seal"
| "stamp"
| "senderSignature"
| "senderStamp";
const DEFAULT_POSITION: LayoutPositionValue = {
top: 0,
left: 0,
};
function buildDefaultAnchors(
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
language: LetterLanguage = "am",
) {
return buildDefaultAbsoluteAnchors(margins, language);
}
function parseCoord(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) return Math.round(parsed);
}
return fallback;
}
export function normalizeLayoutPositionValue(
value: unknown,
anchor?: LayoutPositionAnchor,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
language: LetterLanguage = "am",
): LayoutPositionValue {
const anchors = buildDefaultAnchors(margins, language);
const anchorPos = anchor ? anchors[anchor] : DEFAULT_POSITION;
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return { ...anchorPos };
}
const record = value as Record<string, unknown>;
const hadAbsoluteCoords =
record.top != null ||
record.left != null ||
record.mode === "absolute";
let top: number;
let left: number;
if (hadAbsoluteCoords) {
top =
parseCoord(record.top, anchorPos.top ?? 0) +
parseCoord(record.offsetY, 0);
left =
parseCoord(record.left, anchorPos.left ?? 0) +
parseCoord(record.offsetX, 0);
} else {
top = (anchorPos.top ?? 0) + parseCoord(record.offsetY, 0);
left = (anchorPos.left ?? 0) + parseCoord(record.offsetX, 0);
}
if (anchor && isClosingLayoutAnchor(anchor)) {
top = migrateClosingAssetTopFromPageAbsolute(top, margins);
}
return { top, left };
}
export function toCanvasDisplayPosition(
anchor: LayoutPositionAnchor,
position: LayoutPositionValue,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): LayoutPositionValue {
const top = position.top ?? 0;
const left = position.left ?? 0;
if (isClosingLayoutAnchor(anchor)) {
return {
...position,
top: closingAssetToCanvasTop(top, margins),
left,
};
}
return { ...position, top, left };
}
export function fromCanvasDisplayPosition(
anchor: LayoutPositionAnchor,
position: LayoutPositionValue,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): LayoutPositionValue {
const top = position.top ?? 0;
const left = position.left ?? 0;
if (isClosingLayoutAnchor(anchor)) {
return {
...position,
top: closingAssetFromCanvasTop(top, margins),
left,
};
}
return { ...position, top, left };
}
export function toAbsolutePayloadPosition(
position: LayoutPositionValue,
): Record<string, unknown> {
return {
mode: "absolute",
top: position.top ?? 0,
left: position.left ?? 0,
offsetX: 0,
offsetY: 0,
};
}
export function getLetterCanvasSize(
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
) {
return computeLetterContentAreaPx(margins);
}
export function buildDefaultClosingLanguagePositions(
language: LetterLanguage,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
) {
return {
signatures: normalizeLayoutPositionValue(
null,
"signatures",
margins,
language,
),
seal: normalizeLayoutPositionValue(null, "seal", margins, language),
stamp: normalizeLayoutPositionValue(null, "stamp", margins, language),
};
}
export function buildDefaultPositionsByLanguage(
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
) {
return {
am: buildDefaultClosingLanguagePositions("am", margins),
en: buildDefaultClosingLanguagePositions("en", margins),
};
}

View File

@@ -0,0 +1,353 @@
import React from "react";
import type { LucideIcon } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { cn } from "@/shared/lib/utils";
export const STYLE_SELECT_TRIGGER_CLASS =
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-900 shadow-sm transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800";
export const STYLE_SELECT_CONTENT_CLASS =
"rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900";
export const STYLE_INPUT_CLASS =
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100";
export type StyleSelectOption = {
value: string;
label: React.ReactNode;
disabled?: boolean;
};
type StyleSettingToggleProps = {
isDefault: boolean;
onToggle: () => void;
defaultLabel?: string;
customLabel?: string;
className?: string;
};
export const StyleSettingToggle: React.FC<StyleSettingToggleProps> = ({
isDefault,
onToggle,
defaultLabel = "Default",
customLabel = "Custom",
className,
}) => (
<button
type="button"
onClick={onToggle}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
isDefault ? "bg-gray-200 dark:bg-gray-600" : "bg-purple-600",
className,
)}
aria-label={isDefault ? defaultLabel : customLabel}
title={isDefault ? defaultLabel : customLabel}
>
<span
className={cn(
"absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform",
isDefault ? "left-0.5" : "left-5",
)}
/>
</button>
);
type StyleSettingFieldLabelProps = {
children: React.ReactNode;
className?: string;
htmlFor?: string;
};
export const StyleSettingFieldLabel: React.FC<StyleSettingFieldLabelProps> = ({
children,
className,
htmlFor,
}) => (
<label
htmlFor={htmlFor}
className={cn(
"mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300",
className,
)}
>
{children}
</label>
);
type StyleSettingSelectProps = {
label?: React.ReactNode;
value: string;
onValueChange: (value: string) => void;
options: StyleSelectOption[];
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
};
export const StyleSettingSelect: React.FC<StyleSettingSelectProps> = ({
label,
value,
onValueChange,
options,
placeholder,
disabled,
className,
id,
}) => (
<div className={className}>
{label ? <StyleSettingFieldLabel htmlFor={id}>{label}</StyleSettingFieldLabel> : null}
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger id={id} className={STYLE_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className={STYLE_SELECT_CONTENT_CLASS}>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
className="rounded-md text-sm focus:bg-purple-50 dark:focus:bg-purple-900/30"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
type StyleSettingsSidebarProps = {
title: string;
hint?: string;
children: React.ReactNode;
className?: string;
compact?: boolean;
collapsed?: boolean;
};
export const StyleSettingsSidebar: React.FC<StyleSettingsSidebarProps> = ({
title,
hint,
children,
className,
compact = false,
collapsed = false,
}) => (
<aside
className={cn(
"shrink-0 overflow-y-auto border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800",
collapsed
? "w-16 border-b md:w-16 md:border-b-0 md:border-r"
: compact
? "w-full border-b md:w-52 md:border-b-0 md:border-r"
: "max-h-56 w-full border-b md:max-h-none md:w-56 md:border-b-0 md:border-r lg:w-64",
className,
)}
>
<div className={cn(collapsed ? "p-2" : compact ? "p-3" : "p-4")}>
{!collapsed ? (
<>
<h3 className="mb-1 text-sm font-semibold text-gray-700 dark:text-gray-300">
{title}
</h3>
{hint ? (
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400">{hint}</p>
) : null}
</>
) : (
<p className="sr-only">{title}</p>
)}
<nav className={cn("space-y-1", collapsed && "space-y-2")}>{children}</nav>
</div>
</aside>
);
export type StyleSettingsSidebarItemProps = {
icon?: LucideIcon;
label: React.ReactNode;
meta?: React.ReactNode;
badge?: React.ReactNode;
isActive?: boolean;
disabled?: boolean;
onClick?: () => void;
compact?: boolean;
iconOnly?: boolean;
};
export const StyleSettingsSidebarItem: React.FC<StyleSettingsSidebarItemProps> = ({
icon: Icon,
label,
meta,
badge,
isActive = false,
disabled = false,
onClick,
compact = false,
iconOnly = false,
}) => {
const labelText = typeof label === "string" ? label : undefined;
if (iconOnly && Icon) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
title={labelText}
aria-label={labelText}
className={cn(
"flex w-full items-center justify-center rounded-xl transition-all disabled:cursor-not-allowed disabled:opacity-50",
compact ? "h-9 w-9" : "h-10 w-10",
isActive
? "bg-purple-100 text-purple-700 shadow-sm ring-1 ring-purple-200 dark:bg-purple-900/40 dark:text-purple-200 dark:ring-purple-800"
: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/80",
)}
>
<Icon className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
</button>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"group w-full rounded-xl text-left text-sm transition-all disabled:cursor-not-allowed disabled:opacity-50",
compact ? "px-2.5 py-2" : "px-3 py-2.5",
isActive
? "bg-purple-100 shadow-sm ring-1 ring-purple-200 dark:bg-purple-900/40 dark:ring-purple-800"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/80",
)}
>
<div className="flex items-center gap-3">
{Icon ? (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg transition-colors",
compact ? "h-7 w-7" : "h-8 w-8",
isActive
? "bg-purple-600 text-white"
: "bg-gray-100 text-gray-500 group-hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400",
)}
>
<Icon className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
</span>
) : null}
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate font-medium",
isActive
? "text-purple-800 dark:text-purple-200"
: "text-gray-700 dark:text-gray-300",
)}
>
{label}
</div>
{meta || badge ? (
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-gray-400">
{meta}
{badge}
</div>
) : null}
</div>
</div>
</button>
);
};
type StyleSettingCardProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
code?: string;
isDefault: boolean;
onToggleDefault?: () => void;
showCustomBadge?: boolean;
children: React.ReactNode;
className?: string;
headerExtra?: React.ReactNode;
};
export const StyleSettingCard: React.FC<StyleSettingCardProps> = ({
title,
subtitle,
code,
isDefault,
onToggleDefault,
showCustomBadge = true,
children,
className,
headerExtra,
}) => (
<div
className={cn(
"mt-4 overflow-hidden rounded-xl border bg-white shadow-sm dark:bg-gray-800",
isDefault
? "border-gray-200 dark:border-gray-700"
: "border-purple-200 ring-1 ring-purple-100 dark:border-purple-800 dark:ring-purple-900/40",
className,
)}
>
<div className="flex items-center justify-between gap-4 border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</h4>
{code ? (
<span className="rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{code}
</span>
) : null}
{showCustomBadge && !isDefault ? (
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
Customized
</span>
) : null}
</div>
{subtitle ? (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{subtitle}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{headerExtra}
{onToggleDefault ? (
<StyleSettingToggle isDefault={isDefault} onToggle={onToggleDefault} />
) : null}
</div>
</div>
<div className={cn("p-4", isDefault && "opacity-60")}>{children}</div>
</div>
);
export const StyleSettingPanel: React.FC<{
title?: React.ReactNode;
children: React.ReactNode;
className?: string;
}> = ({ title, children, className }) => (
<div
className={cn(
"rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800/80",
className,
)}
>
{title ? (
<h5 className="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
{title}
</h5>
) : null}
{children}
</div>
);

View File

@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import {
fetchListBulletResources,
LIST_BULLET_RESOURCES_QUERY_KEY,
} from "@/user-management/services/api/listBulletResources";
export { LIST_BULLET_RESOURCES_QUERY_KEY };
export function useListBulletResources() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: LIST_BULLET_RESOURCES_QUERY_KEY,
queryFn: fetchListBulletResources,
});
return {
resources: data ?? [],
loading: isLoading || isFetching,
refetch,
};
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import useSettings from "@/record-management/components/hooks/useSettings";
import { useAuth } from "@/shared/context/AuthContext";
import { TemplateSampleData } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
export const useTemplateFormData = () => {
const { listAllDepartmentResponse, currentEmployeesResponse } = useSettings();
const { user } = useAuth();
const [defaultFormData, setDefaultFormData] =
useState<TemplateSampleData | null>(null);
useEffect(() => {
if (user && currentEmployeesResponse && listAllDepartmentResponse) {
const currentEmployee = currentEmployeesResponse?.items?.find(
(emp: any) => emp.user?.id === user.id,
);
const userPosition = currentEmployee?.employeePositions?.[0];
const userUnit = listAllDepartmentResponse?.find(
(dept: any) => dept.id === userPosition?.position?.id || dept.unitId === userPosition?.id,
);
const formData: TemplateSampleData = {
collaborators: currentEmployee
? [
{
am: currentEmployee.user?.name?.am || "",
en: currentEmployee.user?.name?.en || "",
},
]
: [{ am: "", en: "" }],
preferredLanguage: "am",
content: {
isWithDelegateSignature: false,
delegatorName: currentEmployee?.user?.name?.en || "",
body: "",
date: new Date().toISOString().split("T")[0],
internalCC: [],
externalCC: [],
prefixCC: "",
suffixCC: "",
from: currentEmployee
? [
currentEmployee.user?.name?.am ||
currentEmployee.user?.name?.en ||
"",
]
: [],
subject: "",
sincerelyText: "Sincerely",
to: [],
prefix: "",
suffix: "",
},
recordType: "external",
unitId: userUnit?.id || userUnit?.unitId || "",
};
setDefaultFormData(formData);
}
}, [user, currentEmployeesResponse, listAllDepartmentResponse]);
return {
defaultFormData,
departments: listAllDepartmentResponse || [],
employees: currentEmployeesResponse?.items || [],
};
};

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { TemplateService } from "@/user-management/services/api/templateService";
import { TemplateSampleSetting, TemplateResource } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
export const useTemplateSettings = (templateId: string, unitId?: string) => {
const [settings, setSettings] = useState<TemplateSampleSetting[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!templateId) return;
const fetchSettings = async () => {
try {
setLoading(true);
setError(null);
// First, get all templates to find the one with matching ID
const allTemplatesResponse = await TemplateService.getAllTemplates();
const template = allTemplatesResponse.items?.find(
(t: TemplateResource) => t.id === templateId,
);
if (!template) {
throw new Error("Template not found");
}
// Then fetch settings using the template ID
const fetchedSettings = await TemplateService.getTemplateSettingsByUnit(
template.id,
unitId,
);
setSettings(fetchedSettings.items && fetchedSettings.items.length > 0 ? fetchedSettings.items : getDefaultSettings());
} catch (err) {
console.error("Error loading template settings:", err);
setError("Failed to load template settings");
// Fall back to default settings
setSettings(getDefaultSettings());
} finally {
setLoading(false);
}
};
fetchSettings();
}, [templateId, unitId]);
return {
settings,
loading,
error,
setSettings,
};
};
// Default settings fallback
export const getDefaultSettings = (): TemplateSampleSetting[] => [
{
id: "default-subject-style",
code: "subject-style",
value: {
"font-size": "60px",
"font-weight": "bold",
"text-decoration": "",
},
},
{
id: "default-body-style",
code: "body-style",
value: {
"font-size": "14px",
"font-weight": "normal",
"text-decoration": "",
},
},
];

View File

@@ -0,0 +1,153 @@
/**
* CSS Conversion Utilities
*
* Provides functions for converting between CSS string and object formats,
* with special handling for data URIs containing colons and semicolons, and SVG
* generation for disclosure triangle list styles.
*/
import { isCustomListStyleImage } from "./templateResourceConstants";
/**
* Generates a base64-encoded SVG data URI for a right-pointing disclosure triangle.
*/
export const generateDisclosureTriangleSVG = (): string => {
// Right-pointing triangle SVG (16x16px)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<polygon points="6,4 12,8 6,12" fill="currentColor"/>
</svg>`;
const base64 = btoa(svg);
return `url('data:image/svg+xml;base64,${base64}')`;
};
/**
* Parses CSS declarations from a CSS string, correctly handling data URIs
* with colons and semicolons inside url() values.
*/
export const parseCSSDeclarations = (
cssString: string,
): Array<[string, string]> => {
const results: Array<[string, string]> = [];
let depth = 0;
let current = "";
for (let i = 0; i < cssString.length; i++) {
const ch = cssString[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ';' && depth === 0) {
const decl = current.trim();
if (decl) {
const colonIdx = decl.indexOf(':');
if (colonIdx > 0) {
const prop = decl.slice(0, colonIdx).trim();
const val = decl.slice(colonIdx + 1).trim();
if (prop && val) results.push([prop, val]);
}
}
current = '';
} else {
current += ch;
}
}
// Handle last declaration if no trailing semicolon
const lastDecl = current.trim();
if (lastDecl) {
const colonIdx = lastDecl.indexOf(':');
if (colonIdx > 0) {
const prop = lastDecl.slice(0, colonIdx).trim();
const val = lastDecl.slice(colonIdx + 1).trim();
if (prop && val) results.push([prop, val]);
}
}
return results;
};
/**
* Normalizes list-style properties to handle special values like disclosure-closed.
*/
export const normalizeListStyleProps = (
obj: Record<string, string>,
): Record<string, string> => {
const normalized = { ...obj };
const specialListStyles = ["disclosure-closed", "disclosure-open", "none"];
if (normalized["list-style"]) {
const listStyleValue = normalized["list-style"].split(" ")[0];
if (specialListStyles.includes(listStyleValue)) {
delete normalized["list-style-type"];
return normalized;
}
if (!normalized["list-style-type"]) {
normalized["list-style-type"] = listStyleValue;
}
}
if (normalized["list-style-type"] && !normalized["list-style"]) {
normalized["list-style"] = normalized["list-style-type"];
}
return normalized;
};
/**
* Converts a CSS string to a CSS object, correctly parsing data URIs
* and adding SVG data URI for disclosure-closed.
*/
export const cssStringToObject = (cssString: string): Record<string, string> => {
const obj: Record<string, string> = {};
parseCSSDeclarations(cssString).forEach(([prop, val]) => {
obj[prop] = val;
});
const normalized = normalizeListStyleProps(obj);
if (normalized["list-style"] === "disclosure-closed") {
normalized["list-style-image"] = generateDisclosureTriangleSVG();
} else if (isCustomListStyleImage(normalized["list-style-image"])) {
normalized["list-style"] = normalized["list-style"] || "none";
delete normalized["list-style-type"];
}
return normalized;
};
/**
* Converts a CSS object to a CSS string, formatting as "key: value;" pairs.
*/
export const cssObjectToString = (cssObject: Record<string, string>): string => {
return Object.entries(cssObject)
.filter(([_, value]) => value !== null && value !== undefined)
.map(([key, value]) => `${key}: ${value};`)
.join(' ');
};
export function omitCssProperties(
cssString: string,
properties: string[],
): string {
const omitted = new Set(properties);
const styles: Record<string, string> = {};
parseCSSDeclarations(cssString).forEach(([prop, val]) => {
if (!omitted.has(prop)) {
styles[prop] = val;
}
});
return cssObjectToString(styles);
}
export function mergeCssProperties(
cssString: string,
properties: Record<string, string | undefined>,
): string {
const styles: Record<string, string> = {};
parseCSSDeclarations(cssString).forEach(([prop, val]) => {
styles[prop] = val;
});
Object.entries(properties).forEach(([key, value]) => {
if (value === undefined || value === "") {
delete styles[key];
} else {
styles[key] = value;
}
});
return cssObjectToString(styles);
}

View File

@@ -0,0 +1,132 @@
/** Matches backend pdf-generator A4 dimensions at 96 CSS px. */
export const PDF_PAGE_WIDTH_PX = 794;
export const PDF_PAGE_HEIGHT_PX = 1123;
export type LetterPageMarginsPx = {
top: number;
left: number;
right: number;
bottom: number;
};
export type LetterLanguage = "am" | "en";
export const DEFAULT_LETTER_PAGE_MARGINS_PX: LetterPageMarginsPx = {
top: 120,
left: 30,
right: 20,
bottom: 120,
};
export const CLOSING_ASSET_GAP_AFTER_SINCERELY_PX = 8;
export const ESTIMATED_SINCERELY_BLOCK_HEIGHT_PX = 28;
export type ClosingLayoutAnchor = "signatures" | "seal" | "stamp";
export type AbsoluteLayoutAnchor =
| ClosingLayoutAnchor
| "senderSignature"
| "senderStamp";
export function computeLetterContentAreaPx(
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): { width: number; height: number } {
return {
width: Math.max(
1,
PDF_PAGE_WIDTH_PX - margins.left - margins.right,
),
height: Math.max(
1,
PDF_PAGE_HEIGHT_PX - margins.top - margins.bottom,
),
};
}
function ptToPx(pt: number): number {
return Math.round((pt * 96) / 72);
}
export function isClosingLayoutAnchor(
anchor: AbsoluteLayoutAnchor,
): anchor is ClosingLayoutAnchor {
return anchor === "signatures" || anchor === "seal" || anchor === "stamp";
}
export function estimateClosingBlockTopPx(
_margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): number {
return 480;
}
export function buildDefaultClosingAssetAnchors(
contentWidth: number,
language: LetterLanguage = "am",
): Record<ClosingLayoutAnchor, { top: number; left: number }> {
const sigWidth = ptToPx(96);
const sealWidth = 140;
const sigHeight = 50;
const gap = CLOSING_ASSET_GAP_AFTER_SINCERELY_PX;
const baseTop = ESTIMATED_SINCERELY_BLOCK_HEIGHT_PX + gap;
if (language === "en") {
const columnLeft = 0;
const sealLeft = columnLeft + sigWidth + gap;
return {
signatures: { top: baseTop, left: columnLeft },
seal: { top: baseTop - 12, left: sealLeft },
stamp: { top: baseTop + sigHeight + gap, left: columnLeft },
};
}
const sealLeft = contentWidth - sealWidth;
const columnLeft = sealLeft - gap - sigWidth;
return {
signatures: { top: baseTop, left: columnLeft },
seal: { top: baseTop - 12, left: sealLeft },
stamp: { top: baseTop + sigHeight + gap, left: columnLeft },
};
}
export function buildDefaultAbsoluteAnchors(
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
language: LetterLanguage = "am",
): Record<AbsoluteLayoutAnchor, { top: number; left: number }> {
const { width, height } = computeLetterContentAreaPx(margins);
const closing = buildDefaultClosingAssetAnchors(width, language);
return {
...closing,
senderSignature: {
top: Math.round(height * 0.12),
left: Math.round(width * 0.48),
},
senderStamp: {
top: Math.round(height * 0.12),
left: Math.round(width * 0.64),
},
};
}
export function migrateClosingAssetTopFromPageAbsolute(
top: number,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): number {
const { height } = computeLetterContentAreaPx(margins);
if (top <= height * 0.45) return top;
return top - estimateClosingBlockTopPx(margins);
}
export function closingAssetToCanvasTop(
top: number,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): number {
return top + estimateClosingBlockTopPx(margins);
}
export function closingAssetFromCanvasTop(
top: number,
margins: LetterPageMarginsPx = DEFAULT_LETTER_PAGE_MARGINS_PX,
): number {
return top - estimateClosingBlockTopPx(margins);
}

View File

@@ -0,0 +1,243 @@
/**
* Letter Labels constants & helpers
*
* Customizable, bilingual (English / Amharic) labels used by letter templates
* (To, From, Subject, CC, ...). Each label may carry an optional CSS style
* object (e.g. underline, bold). Stored under the setting code
* `letter-labels-text` as a JSON object.
*/
export type LetterLabelKey =
| "to"
| "from"
| "subject"
| "urgent"
| "cc"
| "date"
| "record"
| "ref_no"
| "delegate"
| "for_your_reference";
/** CSS style map for a single label (kebab-case CSS properties). */
export type LetterLabelStyle = Record<string, string>;
export interface LetterLabelEntry {
en: string;
am: string;
style?: LetterLabelStyle;
}
export type LetterLabelsText = Record<LetterLabelKey, LetterLabelEntry>;
export const LETTER_LABELS_SETTING_CODE = "letter-labels-text";
export const LETTER_LABEL_KEYS: LetterLabelKey[] = [
"to",
"from",
"subject",
"urgent",
"cc",
"date",
"record",
"ref_no",
"delegate",
"for_your_reference",
];
/**
* Default labels for every key in English & Amharic. Some stored values include
* trailing punctuation (Subject:, CC:, Ref No:), others don't (To, From).
*/
export const DEFAULT_LETTER_LABELS: LetterLabelsText = {
to: { en: "To", am: "ለ" },
from: { en: "From", am: "ከ" },
subject: { en: "Subject:", am: "ጉዳዩ:" },
urgent: { en: "Urgent", am: "አስቸኳይ" },
cc: { en: "CC:", am: "ግልባጭ:", style: { "text-decoration": "underline" } },
date: { en: "Date", am: "ቀን" },
record: { en: "Record", am: "መዝገብ" },
ref_no: { en: "Ref No:", am: "ቁጥር:" },
delegate: { en: "Delegate", am: "ተወካይ" },
for_your_reference: { en: "For your reference", am: "ለመረጃ ያህል" },
};
/** Human-friendly field names for the editor (not the printed label text). */
export const LETTER_LABEL_DISPLAY_NAMES: Record<
LetterLabelKey,
{ en: string; am: string }
> = {
to: { en: "To", am: "ለ" },
from: { en: "From", am: "ከ" },
subject: { en: "Subject label", am: "የጉዳይ መለያ" },
urgent: { en: "Urgent", am: "አስቸኳይ" },
cc: { en: "CC", am: "ግልባጭ" },
date: { en: "Date", am: "ቀን" },
record: { en: "Record title", am: "የደብዳቤ ርዕስ" },
ref_no: { en: "Reference number", am: "የማጣቀሻ ቁጥር" },
delegate: { en: "Delegate / Regarding", am: "ስለ" },
for_your_reference: { en: "For your reference", am: "እንዲያውቁት" },
};
export const isLetterLabelsSetting = (code: string): boolean =>
code === LETTER_LABELS_SETTING_CODE;
/** Sidebar section code each label field belongs to. */
export const LETTER_LABEL_CATEGORY_MAP: Record<LetterLabelKey, string> = {
record: "reference-number",
ref_no: "reference-number",
date: "reference-number",
subject: "subject",
urgent: "subject",
to: "receiver",
from: "receiver",
delegate: "body",
cc: "cc",
for_your_reference: "cc",
};
export type LetterLabelSectionCode =
| "reference-number"
| "subject"
| "receiver"
| "body"
| "cc";
/** Normalize API / sidebar section codes to the canonical label section. */
export function normalizeLabelSectionCode(
sectionCode: string,
): LetterLabelSectionCode | null {
const lower = sectionCode.trim().toLowerCase();
if (!lower) return null;
if (
lower.includes("reference-number") ||
lower.includes("reference_number") ||
lower.includes("referencenumber") ||
lower === "reference" ||
(lower.includes("reference") && lower.includes("number")) ||
lower.includes("record-number") ||
lower.includes("record_number")
) {
return "reference-number";
}
if (lower.includes("subject")) return "subject";
if (
lower.includes("receiver") ||
lower.includes("recipient") ||
lower === "receivers"
) {
return "receiver";
}
if (lower.includes("body") || lower.includes("content")) return "body";
if (lower.includes("cc")) return "cc";
return null;
}
const labelMatchesSettingCode = (
labelKey: LetterLabelKey,
settingCode: string,
): boolean => {
const lower = settingCode.trim().toLowerCase();
if (!lower) return false;
switch (labelKey) {
case "to":
return (
(/receiver|recipient|^letter-to\b/i.test(lower) ||
lower.includes("receiver-list")) &&
!/from|collaborator|sender|cc/i.test(lower)
);
case "from":
return /from|collaborator|sender/i.test(lower);
case "subject":
return /subject/i.test(lower) && !/urgent/i.test(lower);
case "urgent":
return /urgent/i.test(lower);
case "record":
return /record/i.test(lower) && !/ref/i.test(lower);
case "ref_no":
return /ref/i.test(lower) && /no|number/i.test(lower);
case "date":
return /date/i.test(lower);
case "delegate":
return /delegate|regarding/i.test(lower) || lower.includes("body");
case "cc":
return (
/letter-cc/i.test(lower) &&
!/list|prefix|suffix|receiver|for-your-reference|foryourreference/i.test(
lower,
)
);
case "for_your_reference":
return /for-your-reference|foryourreference/i.test(lower);
default:
return false;
}
};
export function getLetterLabelKeysForCategory(
categoryCode: string,
settingCodes: string[] = [],
): LetterLabelKey[] {
const canonical = normalizeLabelSectionCode(categoryCode);
const matched = new Set<LetterLabelKey>();
if (canonical) {
for (const key of LETTER_LABEL_KEYS) {
if (LETTER_LABEL_CATEGORY_MAP[key] === canonical) {
matched.add(key);
}
}
}
for (const settingCode of settingCodes) {
for (const key of LETTER_LABEL_KEYS) {
if (labelMatchesSettingCode(key, settingCode)) {
matched.add(key);
}
}
}
return LETTER_LABEL_KEYS.filter((key) => matched.has(key));
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/**
* Validates a CSS style object, keeping only non-empty string values.
* Returns undefined when nothing valid remains.
*/
export const normalizeLabelStyle = (
value: unknown,
): LetterLabelStyle | undefined => {
if (!isPlainObject(value)) return undefined;
const out: LetterLabelStyle = {};
for (const [key, raw] of Object.entries(value)) {
if (key === "font-size") continue;
if (typeof raw === "string" && raw.trim() !== "") out[key] = raw;
}
return Object.keys(out).length > 0 ? out : undefined;
};
/**
* Merges an incoming (possibly partial / invalid) value with the defaults so the
* result always has all keys with valid en/am strings. Preserves style objects.
*/
export const normalizeLetterLabelsValue = (value: unknown): LetterLabelsText => {
const input = isPlainObject(value) ? value : {};
const result = {} as LetterLabelsText;
for (const key of LETTER_LABEL_KEYS) {
const def = DEFAULT_LETTER_LABELS[key];
const raw = isPlainObject(input[key]) ? (input[key] as Record<string, unknown>) : {};
const en = typeof raw.en === "string" ? raw.en : def.en;
const am = typeof raw.am === "string" ? raw.am : def.am;
const style = normalizeLabelStyle(raw.style) ?? def.style;
result[key] = style ? { en, am, style } : { en, am };
}
return result;
};

View File

@@ -0,0 +1,196 @@
import {
TemplateReceiverGroup,
TemplateSampleData,
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
const toNonSmartReceivers = (names: string[]) =>
names.map((name) => ({ type: "non_smart" as const, name }));
const buildReceiverGroup = (
prefix: string,
suffix: string,
names: string[],
): TemplateReceiverGroup => ({
prefix,
suffix,
receivers: toNonSmartReceivers(names),
});
const SAMPLE_CONTENT_EN = {
groupedReceivers: [
{
prefix: "RE:",
items: ["Department Head", "Project Manager", "Team Lead"],
suffix: "---",
},
],
receiverList: [
buildReceiverGroup(
"RE:",
"---",
["Department Head", "Project Manager", "Team Lead"],
),
],
groupedCCReceivers: [
{
items: ["Finance Department", "HR Department"],
suffix: "---",
},
],
groupedForYourReferenceReceivers: [
{
prefix: "For your information:",
items: ["Archive Office"],
suffix: "---",
},
],
forYourReferenceList: [
buildReceiverGroup("For your information:", "---", ["Archive Office"]),
],
content: {
isWithDelegateSignature: false,
delegatorName: "",
body: `This is a sample letter body demonstrating the template styling.
The body can contain multiple paragraphs and formatting.
- First bullet point item
- Second bullet point item
- Third bullet point item
This allows you to see how list styles are applied.`,
date: new Date().toISOString().split("T")[0],
internalCC: ["Finance Department", "HR Department", "Legal Department"],
externalCC: ["External Partner A", "External Partner B"],
prefixCC: "CC:",
suffixCC: "---",
from: ["John Doe", "Mary Smith"],
subject: "Sample Template Subject Style Preview",
sincerelyText: "Sincerely",
to: ["Department Head", "Project Manager", "Team Lead"],
prefix: "RE:",
suffix: "---",
forYourReferenceCC: ["Archive Office"],
},
} as const;
const RECEIVER_NAMES_AM = ["ክፍል ኃላፊ", "የፕሮጀክት ሥራ አስኪያጅ", "የቡድን መሪ"];
const SAMPLE_SUFFIX_AM = "አዲስ አበባ";
const SAMPLE_FYR_PREFIX_AM = "እንዲያውቁት:";
const SAMPLE_CONTENT_AM = {
groupedReceivers: [
{
prefix: "RE:",
items: RECEIVER_NAMES_AM,
suffix: SAMPLE_SUFFIX_AM,
},
],
receiverList: [
buildReceiverGroup(
"የኢትዮጵያ ፌዴራላዊ ዲሞክራሲያዊ ሪፐብሊክ",
SAMPLE_SUFFIX_AM,
RECEIVER_NAMES_AM,
),
],
groupedCCReceivers: [
{
items: ["የፋይናንስ ክፍል", "የሰው ሀብት ክፍል"],
suffix: SAMPLE_SUFFIX_AM,
},
],
groupedForYourReferenceReceivers: [
{
prefix: SAMPLE_FYR_PREFIX_AM,
items: ["የመዝገብ ቤት"],
suffix: SAMPLE_SUFFIX_AM,
},
],
forYourReferenceList: [
buildReceiverGroup(SAMPLE_FYR_PREFIX_AM, SAMPLE_SUFFIX_AM, ["የመዝገብ ቤት"]),
],
content: {
isWithDelegateSignature: false,
delegatorName: "",
body: `ይህ የአብነት ደብዳቤ ይዘት የቅጥ ማቀናበሪያውን ለማሳየት የተዘጋጀ ነው።
ይህ ደብዳቤ በብዙ አንቀጽ እና ቅርጸት ሊቀርጽ ይችላል።
- የመጀመሪያው የነጥብ ዝርዝር
- የሁለተኛው የነጥብ ዝርዝር
- የሦስተኛው የነጥብ ዝርዝር
ይህም የዝርዝር ቅጦች እንዴት እንደሚታዩ ያሳያል።`,
date: new Date().toISOString().split("T")[0],
internalCC: ["የፋይናንስ ክፍል", "የሰው ሀብት ክፍል", "የህጋዊ ጉዳዮች ክፍል"],
externalCC: ["የውጭ አጋር ድርጅት አ", "የውጭ አጋር ድርጅት ቢ"],
prefixCC: "CC:",
suffixCC: SAMPLE_SUFFIX_AM,
from: ["ዮሃንስ ዶ", "ማርያም ስሚዝ"],
subject: "የአብነት ደብዳቤ ጉዳይ - የቅጥ ቅድመ እይታ",
sincerelyText: "በአክብሮት",
to: RECEIVER_NAMES_AM,
prefix: "የኢትዮጵያ ፌዴራላዊ ዲሞክራሲያዊ ሪፐብሊክ",
suffix: SAMPLE_SUFFIX_AM,
forYourReferenceCC: ["የመዝገብ ቤት"],
},
} as const;
const SHARED_SAMPLE_DATA = {
collaborators: [
{ am: "ዮሃንስ ዶ", en: "John Doe" },
{ am: "ማርያም ስሚዝ", en: "Mary Smith" },
],
referenceNumber: "REF-2026/001",
recordType: "external" as const,
};
const cloneGroupedItems = <T extends { items: readonly string[] }>(group: T) => ({
...group,
items: [...group.items],
});
/**
* Sample payload for style-settings PDF preview, localized by preview language.
*/
export function getDefaultSampleData(
preferredLanguage: "am" | "en" = "am",
): Omit<TemplateSampleData, "unitId"> {
const localized =
preferredLanguage === "en" ? SAMPLE_CONTENT_EN : SAMPLE_CONTENT_AM;
return {
...SHARED_SAMPLE_DATA,
preferredLanguage,
...localized,
content: {
...localized.content,
internalCC: [...localized.content.internalCC],
externalCC: [...localized.content.externalCC],
from: [...localized.content.from],
to: [...localized.content.to],
forYourReferenceCC: localized.content.forYourReferenceCC
? [...localized.content.forYourReferenceCC]
: undefined,
},
groupedReceivers: localized.groupedReceivers.map(cloneGroupedItems),
groupedCCReceivers: localized.groupedCCReceivers.map(cloneGroupedItems),
groupedForYourReferenceReceivers:
localized.groupedForYourReferenceReceivers.map(cloneGroupedItems),
receiverList: localized.receiverList.map((group) => ({
...group,
receivers: group.receivers.map((receiver) => ({ ...receiver })),
})),
forYourReferenceList: localized.forYourReferenceList.map((group) => ({
...group,
receivers: group.receivers.map((receiver) => ({ ...receiver })),
})),
};
}
/**
* Default sample data (Amharic). Prefer `getDefaultSampleData(previewLanguage)`
* when generating previews so English content is included.
*/
export const DEFAULT_SAMPLE_DATA: Omit<TemplateSampleData, "unitId"> =
getDefaultSampleData("am");

View File

@@ -0,0 +1,88 @@
export const FONT_RESOURCE_TYPE_ID = "019c4794-c53d-711d-8e2a-aec308a644b2";
export const RESOURCE_LIST_STYLE_PREFIX = "resource:";
export interface ListBulletResource {
id: string;
name: { en?: string; am?: string };
presigned: string;
resourceTypeId: string;
fileInfo?: {
bucket?: string;
fileName?: string;
contentType?: string;
size?: number;
originalname?: string;
};
}
const LIST_BULLET_CODE_PATTERNS = [
/^bullet/i,
/^bullets/i,
/^list[-_]?bullet/i,
/^list[-_]?style/i,
/^list[-_]?icon/i,
/^icon/i,
/^image/i,
];
export function isFontResourceType(type: {
id: string;
code?: string;
}): boolean {
return (
type.id === FONT_RESOURCE_TYPE_ID || /font/i.test(type.code || "")
);
}
export function isListBulletResourceType(type: {
id: string;
code?: string;
name?: string | { en?: string; am?: string };
}): boolean {
if (isFontResourceType(type)) return false;
const code = (type.code || "").toLowerCase();
if (LIST_BULLET_CODE_PATTERNS.some((pattern) => pattern.test(code))) {
return true;
}
const rawName = type.name;
const nameEn =
typeof rawName === "string"
? rawName.toLowerCase()
: (rawName?.en || "").toLowerCase();
const nameAm =
typeof rawName === "string"
? rawName.toLowerCase()
: (rawName?.am || "").toLowerCase();
return (
nameEn.includes("bullet") ||
nameAm.includes("bullet") ||
nameEn.includes("list style") ||
nameEn.includes("list-style")
);
}
export function isResourceListStyle(value: string): boolean {
return value.startsWith(RESOURCE_LIST_STYLE_PREFIX);
}
export function toResourceListStyleValue(resourceId: string): string {
return `${RESOURCE_LIST_STYLE_PREFIX}${resourceId}`;
}
export function isCustomListStyleImage(value?: string): boolean {
if (!value || !value.startsWith("url(")) return false;
return !value.includes("data:image/svg+xml");
}
export function extractUrlFromListStyleImage(value?: string): string | null {
if (!value?.startsWith("url(")) return null;
const match = value.match(/^url\(\s*['"]?([^'")]+)['"]?\s*\)$/i);
return match?.[1] ?? null;
}
export function isDisclosureListStyleImage(value?: string): boolean {
return Boolean(value?.includes("data:image/svg+xml"));
}

View File

@@ -0,0 +1,183 @@
import {
TemplateSampleFileInfo,
TemplateSampleRequest,
TemplateSampleRequestSetting,
TemplateSampleResource,
TemplateSampleSetting,
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { isFontSetting } from "../components/FontSettingsEditor";
import {
isCustomListStyleImage,
ListBulletResource,
} from "./templateResourceConstants";
export function isListStyleSettingCode(code: string): boolean {
return code.trim().toLowerCase().includes("list-style");
}
export function extractListStyleResourceId(
value: Record<string, string>,
bulletResources: ListBulletResource[],
): string | null {
const imageValue = value["list-style-image"];
if (isCustomListStyleImage(imageValue)) {
const match = bulletResources.find(
(resource) =>
imageValue.includes(resource.presigned) ||
imageValue.includes(resource.id) ||
Boolean(
resource.fileInfo?.fileName &&
imageValue.includes(resource.fileInfo.fileName),
),
);
return match?.id ?? null;
}
if (value["list-style-type"] === "custom-image") {
return null;
}
return null;
}
export function resolveSettingResourceId(
setting: Pick<TemplateSampleSetting, "id" | "code">,
value: Record<string, unknown>,
resourceIds: Record<string, string | null>,
bulletResources: ListBulletResource[],
): string | null {
if (isFontSetting(setting.code)) {
return resourceIds[setting.id] ?? null;
}
const cssValue = value as Record<string, string>;
if (cssValue["list-style-type"] === "custom-image") {
return resourceIds[setting.id] ?? null;
}
return (
resourceIds[setting.id] ??
extractListStyleResourceId(cssValue, bulletResources)
);
}
export function formatSettingValueForApi(
value: Record<string, unknown>,
resourceId: string | null,
settingCode: string,
): Record<string, unknown> {
const formatted: Record<string, unknown> = { ...value };
if (!isListStyleSettingCode(settingCode)) {
return formatted;
}
if (resourceId) {
delete formatted["list-style-image"];
delete formatted["list-style"];
formatted["list-style-type"] = "custom-image";
return formatted;
}
delete formatted["list-style-image"];
const listStyle =
(formatted["list-style"] as string | undefined)?.split(" ")[0] ||
(formatted["list-style-type"] as string | undefined);
if (
listStyle &&
listStyle !== "custom-image" &&
listStyle !== "none"
) {
formatted["list-style-type"] = listStyle;
formatted["list-style"] = listStyle;
}
return formatted;
}
function toSampleFileInfo(
fileInfo?: ListBulletResource["fileInfo"],
): TemplateSampleFileInfo | undefined {
if (!fileInfo) return undefined;
return {
bucket: fileInfo.bucket || "",
originalname: fileInfo.originalname || "",
contentType: fileInfo.contentType || "application/octet-stream",
fileName: fileInfo.fileName,
size: fileInfo.size,
};
}
export function buildSampleResources(
resourceIds: Array<string | null | undefined>,
bulletResources: ListBulletResource[],
): TemplateSampleResource[] {
const uniqueIds = [
...new Set(resourceIds.filter((id): id is string => Boolean(id))),
];
return uniqueIds.map((resourceId) => {
const resource = bulletResources.find((item) => item.id === resourceId);
return {
resourceId,
presigned: resource?.presigned,
fileInfo: toSampleFileInfo(resource?.fileInfo),
};
});
}
export function buildSampleSettingPayload(
setting: TemplateSampleSetting,
value: Record<string, unknown>,
resourceIds: Record<string, string | null>,
bulletResources: ListBulletResource[],
): TemplateSampleRequestSetting {
const resourceId = resolveSettingResourceId(
setting,
value,
resourceIds,
bulletResources,
);
return {
id: setting.id,
code: setting.code,
value: formatSettingValueForApi(value, resourceId, setting.code),
resourceId: resourceId ?? null,
};
}
export function buildTemplateSamplePayload(input: {
data: TemplateSampleRequest["data"];
settings: TemplateSampleSetting[];
resourceIds: Record<string, string | null>;
bulletResources: ListBulletResource[];
resolveSettingValue: (
setting: TemplateSampleSetting,
) => Record<string, unknown>;
}): TemplateSampleRequest {
const settingsForAPI = input.settings.map((setting) =>
buildSampleSettingPayload(
setting,
input.resolveSettingValue(setting),
input.resourceIds,
input.bulletResources,
),
);
const resources = buildSampleResources(
settingsForAPI.map((setting) => setting.resourceId),
input.bulletResources,
);
return {
data: input.data,
settings: settingsForAPI,
resources,
};
}

View File

@@ -0,0 +1,64 @@
import { TemplateSampleSetting } from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import { parseCSSDeclarations } from "./cssConversion";
export const DEFAULT_LETTER_FONT_FAMILY_AM =
"'Visual Geez Unicode', 'Visual Geez', serif";
export const DEFAULT_LETTER_FONT_FAMILY_EN =
"'Times New Roman', Times, serif";
export const DEFAULT_LETTER_FONT_SIZE = "12pt";
const LIST_FORMATTING_PROPERTY_KEYS = new Set([
"list-style",
"list-style-type",
"list-style-image",
"list-style-position",
]);
export function getDefaultLetterFontFamily(language: "am" | "en"): string {
return language === "am"
? DEFAULT_LETTER_FONT_FAMILY_AM
: DEFAULT_LETTER_FONT_FAMILY_EN;
}
export function getDefaultLetterFontSize(): string {
return DEFAULT_LETTER_FONT_SIZE;
}
function collectSettingPropertyKeys(
setting: Pick<TemplateSampleSetting, "value" | "templateSettingValues">,
): Set<string> {
const keys = new Set<string>();
setting.templateSettingValues?.forEach((entry) => {
if (entry.value && typeof entry.value === "object") {
Object.keys(entry.value).forEach((key) => keys.add(key));
}
});
if (
typeof setting.value === "object" &&
setting.value !== null &&
!Array.isArray(setting.value)
) {
Object.keys(setting.value).forEach((key) => keys.add(key));
} else if (typeof setting.value === "string" && setting.value.trim()) {
parseCSSDeclarations(setting.value).forEach(([key]) => keys.add(key));
}
return keys;
}
/** True when the setting schema includes list-related CSS properties. */
export function settingSupportsListFormatting(
setting: Pick<TemplateSampleSetting, "value" | "templateSettingValues">,
): boolean {
const keys = collectSettingPropertyKeys(setting);
for (const key of LIST_FORMATTING_PROPERTY_KEYS) {
if (keys.has(key)) return true;
}
return false;
}
export function isFontSetting(code: string): boolean {
return code.trim().toLowerCase() === "letter-fonts";
}

View File

@@ -0,0 +1,214 @@
import {
TemplateSampleSetting,
TemplateSettingValueBulkItem,
TemplateSettingValueScope,
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
import {
isLetterLabelsSetting,
normalizeLetterLabelsValue,
} from "./letterLabelsConstants";
import {
isPageMarginSetting,
normalizePageMarginValue,
} from "../components/PageMarginEditor";
import {
isLayoutConfigSetting,
normalizeLayoutConfigValue,
} from "../components/LayoutConfigEditor";
import { ListBulletResource } from "./templateResourceConstants";
import {
formatSettingValueForApi,
resolveSettingResourceId,
} from "./templateSamplePayloadUtils";
export type BuiltSettingPayloadItem = {
id: string;
code: string;
value: Record<string, unknown>;
resourceId: string | null;
};
export type SettingBaseline = {
value: Record<string, unknown>;
resourceId: string | null;
usesGlobalDefault: boolean;
};
function stableSerialize(value: unknown): string {
return JSON.stringify(value ?? null);
}
export function buildSettingBaselines(
payloadItems: BuiltSettingPayloadItem[],
settings: TemplateSampleSetting[],
): Record<string, SettingBaseline> {
const overrideById = new Map(settings.map((setting) => [setting.id, setting]));
const baselines: Record<string, SettingBaseline> = {};
for (const item of payloadItems) {
const setting = overrideById.get(item.id);
baselines[item.id] = {
value: item.value,
resourceId: item.resourceId,
usesGlobalDefault: !setting?.isUnitOverride,
};
}
return baselines;
}
export function buildSettingsPayloadItems(
settings: TemplateSampleSetting[],
resourceIds: Record<string, string | null>,
bulletResources: ListBulletResource[],
cssValueToPreviewObject: (cssString: string) => Record<string, string>,
): BuiltSettingPayloadItem[] {
return settings.map((setting) => {
if (isLetterLabelsSetting(setting.code)) {
return {
id: setting.id,
code: setting.code,
value: normalizeLetterLabelsValue(setting.value),
resourceId: null,
};
}
if (isPageMarginSetting(setting.code)) {
return {
id: setting.id,
code: setting.code,
value: normalizePageMarginValue(setting.value) as unknown as Record<
string,
unknown
>,
resourceId: null,
};
}
if (isLayoutConfigSetting(setting.code)) {
return {
id: setting.id,
code: setting.code,
value: normalizeLayoutConfigValue(setting.value) as unknown as Record<
string,
unknown
>,
resourceId: null,
};
}
const cssString = typeof setting.value === "string" ? setting.value : "";
const valueObj = cssValueToPreviewObject(cssString);
const resourceId = resolveSettingResourceId(
setting,
valueObj,
resourceIds,
bulletResources,
);
const formattedValue = formatSettingValueForApi(
valueObj,
resourceId,
setting.code,
);
return {
id: setting.id,
code: setting.code,
value: formattedValue,
resourceId,
};
});
}
export function filterChangedSettingOverrides(
items: BuiltSettingPayloadItem[],
baselines: Record<string, SettingBaseline>,
useDefaultSettings: Record<string, boolean>,
): BuiltSettingPayloadItem[] {
return items.filter((item) => {
const baseline = baselines[item.id];
if (!baseline) return true;
const usesGlobalDefault = useDefaultSettings[item.id] ?? true;
const valueChanged =
stableSerialize(item.value) !== stableSerialize(baseline.value);
const resourceChanged = (item.resourceId ?? null) !== baseline.resourceId;
const defaultStateChanged = usesGlobalDefault !== baseline.usesGlobalDefault;
return valueChanged || resourceChanged || defaultStateChanged;
});
}
/** Preview overrides must not carry unit resource ids when reverting to template default. */
export function finalizePreviewOverrides(
items: BuiltSettingPayloadItem[],
useDefaultSettings: Record<string, boolean>,
baselines: Record<string, SettingBaseline>,
): BuiltSettingPayloadItem[] {
return items.map((item) => {
const usesGlobalDefault = useDefaultSettings[item.id] ?? true;
const baseline = baselines[item.id];
const revertedToDefault =
usesGlobalDefault && baseline && !baseline.usesGlobalDefault;
if (!revertedToDefault) {
return item;
}
return {
...item,
resourceId: null,
};
});
}
export function buildBulkSavePayload(options: {
settings: TemplateSampleSetting[];
payloadItems: BuiltSettingPayloadItem[];
useDefaultSettings: Record<string, boolean>;
scope: TemplateSettingValueScope;
baselines?: Record<string, SettingBaseline>;
}): TemplateSettingValueBulkItem[] {
const { settings, payloadItems, useDefaultSettings, scope, baselines } =
options;
const payloadById = new Map(payloadItems.map((item) => [item.id, item]));
const bulk: TemplateSettingValueBulkItem[] = [];
for (const setting of settings) {
const usesGlobalDefault = useDefaultSettings[setting.id] ?? true;
const baseline = baselines?.[setting.id];
if (usesGlobalDefault) {
if (scope === "unit" && setting.isUnitOverride) {
bulk.push({
templateSettingId: setting.id,
revertToDefault: true,
});
}
continue;
}
const item = payloadById.get(setting.id);
if (!item) continue;
if (baseline) {
const valueChanged =
stableSerialize(item.value) !== stableSerialize(baseline.value);
const resourceChanged = (item.resourceId ?? null) !== baseline.resourceId;
const defaultStateChanged =
usesGlobalDefault !== baseline.usesGlobalDefault;
if (!valueChanged && !resourceChanged && !defaultStateChanged) {
continue;
}
}
bulk.push({
templateSettingId: setting.id,
resourceId: item.resourceId,
value: item.value,
scope,
});
}
return bulk;
}