mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
fix ui
This commit is contained in:
@@ -1,34 +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;
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,105 +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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -1,90 +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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,146 +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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,108 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,91 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,127 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,240 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,111 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,74 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,167 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,252 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,72 +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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +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>
|
||||
);
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
@@ -1,179 +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),
|
||||
};
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,353 +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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,20 +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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,68 +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 || [],
|
||||
};
|
||||
};
|
||||
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 || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,75 +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": "",
|
||||
},
|
||||
},
|
||||
];
|
||||
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": "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,153 +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);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,132 +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);
|
||||
}
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -1,243 +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;
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
@@ -1,196 +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");
|
||||
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");
|
||||
|
||||
@@ -1,88 +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"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -1,183 +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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,64 +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";
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -1,214 +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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user