mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1759 lines
70 KiB
TypeScript
1759 lines
70 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { sanitizeHtml } from "@/shared/lib/sanitize";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ArrowLeft, Loader, X, Plus } from "lucide-react";
|
||
import { TemplateService } from "@/user-management/services/api/templateService";
|
||
import {
|
||
TemplateSampleRequest,
|
||
TemplateResource,
|
||
TemplateSampleData,
|
||
TemplateSampleSetting,
|
||
} from "@/user-management/services/TemplateConfiguration/types/templateTypes";
|
||
import { useTemplateFormData } from "./hooks/useTemplateFormData";
|
||
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
|
||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||
import { toast } from "sonner";
|
||
import { TemplateLivePreview } from "./TemplateLivePreview";
|
||
import {
|
||
isLetterLabelsSetting,
|
||
normalizeLetterLabelsValue,
|
||
} from "./utils/letterLabelsConstants";
|
||
import {
|
||
isPageMarginSetting,
|
||
normalizePageMarginValue,
|
||
} from "./components/PageMarginEditor";
|
||
import { StyleSettingsEditor } from "./components/StyleSettingsEditor";
|
||
import CollapsibleSection from "./CollapsibleSection";
|
||
import {
|
||
normalizeListStyleProps,
|
||
styleObjectToCss,
|
||
parseCSSDeclarations,
|
||
cssStringToObject,
|
||
parseCSSStringToObject,
|
||
} from "./TemplateSampleForm.utils";
|
||
import { useListBulletResources } from "./hooks/useListBulletResources";
|
||
import { buildTemplateSamplePayload } from "./utils/templateSamplePayloadUtils";
|
||
|
||
interface TemplateSampleFormProps {
|
||
templateId: string;
|
||
onBack: () => void;
|
||
onGeneratePreview: (
|
||
pdfUrl: string,
|
||
valuesToSave: Array<{
|
||
templateSettingId: string;
|
||
resourceId: string | null;
|
||
value: Record<string, string>;
|
||
}>,
|
||
) => void;
|
||
initialPayload?: {
|
||
data: TemplateSampleData;
|
||
settings: TemplateSampleSetting[];
|
||
};
|
||
}
|
||
|
||
export const TemplateSampleForm = ({
|
||
templateId,
|
||
onBack,
|
||
onGeneratePreview,
|
||
initialPayload,
|
||
}: TemplateSampleFormProps) => {
|
||
const { t } = useTranslation();
|
||
const { defaultFormData, departments, employees } = useTemplateFormData();
|
||
const { userDetails } = useAuthUser();
|
||
const unitId = userDetails?.employee?.[0]?.unitId;
|
||
const [loading, setLoading] = useState(false);
|
||
const [template, setTemplate] = useState<TemplateResource | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedCollaborators, setSelectedCollaborators] = useState<string[]>(
|
||
[],
|
||
);
|
||
const [selectedInternalCC, setSelectedInternalCC] = useState<string[]>([]);
|
||
const [selectedExternalCC, setSelectedExternalCC] = useState<string[]>([]);
|
||
const [selectedRecipients, setSelectedRecipients] = useState<string[]>([]);
|
||
|
||
const [formData, setFormData] = useState<TemplateSampleData>({
|
||
collaborators: [
|
||
{ am: "ዮሃንስ ዶ", en: "John Doe" },
|
||
{ am: "ማርያም ስሚዝ", en: "Mary Smith" },
|
||
],
|
||
preferredLanguage: "am",
|
||
content: {
|
||
isWithDelegateSignature: false,
|
||
delegatorName: "",
|
||
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\n- First item\n- Second item\n- Third item",
|
||
date: new Date().toISOString().split("T")[0],
|
||
internalCC: ["Finance Department", "HR Department"],
|
||
externalCC: ["External Unit 1", "External Unit 2"],
|
||
prefixCC: "CC:",
|
||
suffixCC: "---",
|
||
from: ["John Doe", "Mary Smith"],
|
||
subject: "Sample Letter Subject - Important Notice",
|
||
sincerelyText: "Sincerely",
|
||
to: ["Jane Smith", "Robert Johnson"],
|
||
prefix: "RE:",
|
||
suffix: "---",
|
||
},
|
||
recordType: "external",
|
||
});
|
||
|
||
const [selectedHeaderId, setSelectedHeaderId] = useState<string>("");
|
||
const [selectedFooterId, setSelectedFooterId] = useState<string>("");
|
||
const [selectedHeader, setSelectedHeader] = useState<string>("");
|
||
const [selectedFooter, setSelectedFooter] = useState<string>("");
|
||
|
||
// Use the same hook as record form for headers/footers
|
||
const { data: headerFooterData, isLoading: isHeaderFooterLoading } =
|
||
useHeaderFooterPresigned(unitId!);
|
||
|
||
const headers = headerFooterData?.headers || [];
|
||
const footers = headerFooterData?.footers || [];
|
||
|
||
// Settings state with default/custom tracking
|
||
const [settings, setSettings] = useState<TemplateSampleSetting[]>([]);
|
||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||
const [useDefaultSettings, setUseDefaultSettings] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [customSettings, setCustomSettings] = useState<Record<string, string>>(
|
||
{},
|
||
);
|
||
const [resourceIds, setResourceIds] = useState<Record<string, string | null>>(
|
||
{},
|
||
);
|
||
const { resources: bulletResources } = useListBulletResources();
|
||
const [payloadSample, setPayloadSample] = useState<TemplateSampleRequest>();
|
||
// State for collapsible sections
|
||
const [expandedSections, setExpandedSections] = useState({
|
||
basicInfo: true,
|
||
headerFooter: false,
|
||
content: false,
|
||
collaborators: false,
|
||
recipients: false,
|
||
});
|
||
|
||
const toggleSection = (section: keyof typeof expandedSections) => {
|
||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (templateId) {
|
||
loadTemplate();
|
||
// Only load settings if they haven't been loaded yet
|
||
if (settings.length === 0) {
|
||
loadSettings();
|
||
}
|
||
}
|
||
|
||
// Cleanup timer on unmount
|
||
return () => {
|
||
if (settingsDebounceTimer.current) {
|
||
clearTimeout(settingsDebounceTimer.current);
|
||
}
|
||
};
|
||
}, [templateId]);
|
||
|
||
// Load initial payload if provided
|
||
useEffect(() => {
|
||
if (initialPayload) {
|
||
// Set form data from payload
|
||
if (initialPayload.data) {
|
||
setFormData(initialPayload.data);
|
||
}
|
||
|
||
// Set settings from payload - use the outer 'value' property
|
||
if (initialPayload.settings && initialPayload.settings.length > 0) {
|
||
const processedSettings = initialPayload.settings.map((setting) => {
|
||
let cssString = "";
|
||
|
||
// Use the outer 'value' property, not templateSettingValues
|
||
const valueToUse = setting.value;
|
||
|
||
// Typed (non-CSS) settings keep their object value intact.
|
||
if (isLetterLabelsSetting(setting.code)) {
|
||
return { ...setting, value: normalizeLetterLabelsValue(valueToUse) };
|
||
}
|
||
if (isPageMarginSetting(setting.code)) {
|
||
return { ...setting, value: normalizePageMarginValue(valueToUse) };
|
||
}
|
||
|
||
// Convert object to CSS string if needed
|
||
if (typeof valueToUse === "object" && valueToUse !== null) {
|
||
cssString = Object.entries(valueToUse)
|
||
.map(([key, value]) => {
|
||
// Skip null values but keep SVG data URIs
|
||
if (value === null || value === "null") return null;
|
||
return `${key}: ${value};`;
|
||
})
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
} else if (typeof valueToUse === "string") {
|
||
cssString = valueToUse;
|
||
}
|
||
|
||
return {
|
||
...setting,
|
||
value: cssString,
|
||
};
|
||
});
|
||
|
||
setSettings(processedSettings);
|
||
|
||
// Mark all as using defaults initially
|
||
const defaultMap: Record<string, boolean> = {};
|
||
processedSettings.forEach((setting) => {
|
||
defaultMap[setting.id] = true;
|
||
});
|
||
setUseDefaultSettings(defaultMap);
|
||
}
|
||
}
|
||
}, [initialPayload]);
|
||
|
||
useEffect(() => {
|
||
if (!defaultFormData) return;
|
||
|
||
// Only update with defaultFormData if it has meaningful values
|
||
// Otherwise keep the initialized defaults from component state
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
recordType: defaultFormData.recordType || prev.recordType,
|
||
preferredLanguage:
|
||
defaultFormData.preferredLanguage || prev.preferredLanguage,
|
||
collaborators:
|
||
defaultFormData.collaborators?.length > 0
|
||
? defaultFormData.collaborators
|
||
: prev.collaborators,
|
||
content: {
|
||
...prev.content,
|
||
isWithDelegateSignature:
|
||
defaultFormData.content.isWithDelegateSignature ??
|
||
prev.content.isWithDelegateSignature,
|
||
delegatorName:
|
||
defaultFormData.content.delegatorName || prev.content.delegatorName,
|
||
// Keep initialized defaults for body, subject, etc. if defaultFormData doesn't have them
|
||
body: defaultFormData.content.body || prev.content.body,
|
||
date: defaultFormData.content.date || prev.content.date,
|
||
subject: defaultFormData.content.subject?.trim()
|
||
? defaultFormData.content.subject
|
||
: prev.content.subject,
|
||
sincerelyText:
|
||
defaultFormData.content.sincerelyText || prev.content.sincerelyText,
|
||
prefix: defaultFormData.content.prefix || prev.content.prefix,
|
||
suffix: defaultFormData.content.suffix || prev.content.suffix,
|
||
// Keep initialized defaults for CC and recipients if defaultFormData doesn't have them
|
||
internalCC:
|
||
defaultFormData.content.internalCC?.length > 0
|
||
? defaultFormData.content.internalCC
|
||
: prev.content.internalCC,
|
||
externalCC:
|
||
defaultFormData.content.externalCC?.length > 0
|
||
? defaultFormData.content.externalCC
|
||
: prev.content.externalCC,
|
||
prefixCC: defaultFormData.content.prefixCC || prev.content.prefixCC,
|
||
suffixCC: defaultFormData.content.suffixCC || prev.content.suffixCC,
|
||
from:
|
||
defaultFormData.content.from?.length > 0
|
||
? defaultFormData.content.from
|
||
: prev.content.from,
|
||
to:
|
||
defaultFormData.content.to?.length > 0
|
||
? defaultFormData.content.to
|
||
: prev.content.to,
|
||
},
|
||
}));
|
||
}, [defaultFormData]);
|
||
|
||
// Keep UI selection state in sync when form data/defaults are loaded.
|
||
useEffect(() => {
|
||
if (!departments.length && !employees.length) return;
|
||
|
||
const recipientIds = (formData.content.to || [])
|
||
.map((name) => {
|
||
const dept = departments.find(
|
||
(d: any) => (d?.name?.en || d?.name?.am) === name,
|
||
);
|
||
if (dept?.id) return dept.id;
|
||
const emp = employees.find(
|
||
(e: any) => (e?.user?.name?.en || e?.user?.name?.am) === name,
|
||
);
|
||
return emp?.employeePositions?.[0]?.id || "";
|
||
})
|
||
.filter(Boolean);
|
||
|
||
const internalCcIds = (formData.content.internalCC || [])
|
||
.map((name) => {
|
||
const dept = departments.find(
|
||
(d: any) => (d?.name?.en || d?.name?.am) === name,
|
||
);
|
||
return dept?.id || "";
|
||
})
|
||
.filter(Boolean);
|
||
|
||
const collaboratorIds = (formData.collaborators || [])
|
||
.map((collab) => {
|
||
const fullName = collab.en || collab.am;
|
||
const emp = employees.find(
|
||
(e: any) => (e?.user?.name?.en || e?.user?.name?.am) === fullName,
|
||
);
|
||
return emp?.employeePositions?.[0]?.id || "";
|
||
})
|
||
.filter(Boolean);
|
||
|
||
setSelectedRecipients(recipientIds);
|
||
setSelectedInternalCC(internalCcIds);
|
||
setSelectedExternalCC(formData.content.externalCC || []);
|
||
setSelectedCollaborators(collaboratorIds);
|
||
}, [
|
||
formData.content.to,
|
||
formData.content.internalCC,
|
||
formData.content.externalCC,
|
||
formData.collaborators,
|
||
departments,
|
||
employees,
|
||
]);
|
||
|
||
// Update header/footer previews when selected or when headers/footers load
|
||
useEffect(() => {
|
||
if (headers.length) {
|
||
if (selectedHeaderId) {
|
||
const h = headers.find((x) => x.id === selectedHeaderId);
|
||
if (h)
|
||
setSelectedHeader(
|
||
`<div class="letter-header"><img src="${h.presigned}" alt="Header" style="width: 100%; max-height: 200px;" /></div>`,
|
||
);
|
||
} else {
|
||
// Show first header by default
|
||
setSelectedHeader(
|
||
`<div class="letter-header"><img src="${headers[0].presigned}" alt="Header" style="width: 100%; max-height: 200px;" /></div>`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (footers.length) {
|
||
if (selectedFooterId) {
|
||
const f = footers.find((x) => x.id === selectedFooterId);
|
||
if (f)
|
||
setSelectedFooter(
|
||
`<div class="letter-footer"><img src="${f.presigned}" alt="Footer" style="width: 100%; max-height: 200px;" /></div>`,
|
||
);
|
||
} else {
|
||
// Show first footer by default
|
||
setSelectedFooter(
|
||
`<div class="letter-footer"><img src="${footers[0].presigned}" alt="Footer" style="width: 100%; max-height: 200px;" /></div>`,
|
||
);
|
||
}
|
||
}
|
||
}, [headers, footers, selectedHeaderId, selectedFooterId]);
|
||
|
||
const loadTemplate = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await TemplateService.getTemplate(templateId);
|
||
setTemplate(data);
|
||
} catch (err) {
|
||
setError(t("error.loadingTemplate", "Error loading template"));
|
||
console.error(err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
setSettingsLoading(true);
|
||
setError(null);
|
||
const response =
|
||
await TemplateService.getTemplateSettingsByUnit(templateId);
|
||
const fetchedSettings = Array.isArray(response?.items)
|
||
? response.items
|
||
: [];
|
||
// ─── RAW API RESPONSE ────────────────────────────────────────────────
|
||
console.groupCollapsed(
|
||
`%c[TemplateSettings] Raw fetch — ${fetchedSettings.length} settings`,
|
||
"color:#6366f1; font-weight:bold;",
|
||
);
|
||
console.table(
|
||
fetchedSettings.map((s: any) => ({
|
||
code: s.code,
|
||
id: s.id,
|
||
rank: s.rank,
|
||
outerValue: JSON.stringify(s.value),
|
||
settingValuesCount: s.templateSettingValues?.length ?? 0,
|
||
})),
|
||
);
|
||
console.groupEnd();
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
if (fetchedSettings.length === 0) {
|
||
console.warn("No template settings found for template:", templateId);
|
||
}
|
||
|
||
// Initialize settings with default values from API
|
||
const initializedSettings = fetchedSettings
|
||
.map((setting: any) => {
|
||
// Ensure setting has required fields
|
||
if (!setting.id || !setting.code) {
|
||
console.warn("Setting missing required fields:", setting);
|
||
return null;
|
||
}
|
||
|
||
let defaultValue: any = undefined;
|
||
let chosenFrom = "none";
|
||
|
||
// ── 1. Prefer the outer `value` – this is the API's resolved current value
|
||
// (e.g. what was saved last time the user clicked Generate Sample)
|
||
if (setting.value !== null && setting.value !== undefined) {
|
||
defaultValue = setting.value;
|
||
chosenFrom = "outer setting.value (API resolved)";
|
||
}
|
||
// ── 2. Fall back to templateSettingValues – prioritise entry with a unitId
|
||
else if (setting.templateSettingValues?.length > 0) {
|
||
const valueWithUnitId = setting.templateSettingValues.find(
|
||
(tv: any) => tv.unitId !== null && tv.unitId !== undefined,
|
||
);
|
||
if (valueWithUnitId?.value != null) {
|
||
defaultValue = valueWithUnitId.value;
|
||
chosenFrom = `templateSettingValues (unitId: ${valueWithUnitId.unitId})`;
|
||
} else {
|
||
const fallback = setting.templateSettingValues.find(
|
||
(tv: any) => tv.value !== null && tv.value !== undefined,
|
||
);
|
||
defaultValue = fallback?.value;
|
||
chosenFrom = fallback
|
||
? "templateSettingValues (no unitId)"
|
||
: "none";
|
||
}
|
||
}
|
||
|
||
// Typed (non-CSS) settings keep their object value intact.
|
||
if (isLetterLabelsSetting(setting.code)) {
|
||
return { ...setting, value: normalizeLetterLabelsValue(defaultValue) };
|
||
}
|
||
if (isPageMarginSetting(setting.code)) {
|
||
return { ...setting, value: normalizePageMarginValue(defaultValue) };
|
||
}
|
||
|
||
// Convert the resolved value object → CSS string
|
||
let cssString = "";
|
||
if (
|
||
typeof defaultValue === "object" &&
|
||
defaultValue !== null &&
|
||
Object.keys(defaultValue).length > 0
|
||
) {
|
||
cssString = Object.entries(defaultValue)
|
||
.filter(([, v]) => v !== null && v !== undefined && v !== "null")
|
||
.map(([key, value]) => `${key}: ${value};`)
|
||
.join(" ");
|
||
} else if (typeof defaultValue === "string" && defaultValue.trim()) {
|
||
cssString = defaultValue;
|
||
}
|
||
|
||
// ─── PER-SETTING DEBUG ─────────────────────────────────────────────
|
||
console.groupCollapsed(
|
||
`%c[TemplateSettings] ${setting.code}`,
|
||
"color:#0ea5e9; font-weight:bold;",
|
||
);
|
||
console.log(" Full setting object: ", setting);
|
||
console.log(" Outer value (API): ", setting.value);
|
||
console.log(
|
||
" templateSettingValues: ",
|
||
setting.templateSettingValues,
|
||
);
|
||
console.log(" ✅ Chosen from: ", chosenFrom);
|
||
console.log(" Raw defaultValue used: ", defaultValue);
|
||
console.log(
|
||
" Final CSS string: ",
|
||
cssString || "(empty — no value)",
|
||
);
|
||
console.groupEnd();
|
||
// ──────────────────────────────────────────────────────────────────
|
||
|
||
return {
|
||
...setting,
|
||
value: cssString,
|
||
};
|
||
})
|
||
.filter((s: any) => s !== null);
|
||
|
||
setSettings(initializedSettings);
|
||
|
||
const loadedResourceIds: Record<string, string | null> = {};
|
||
initializedSettings.forEach((setting) => {
|
||
if (!setting) return;
|
||
const unitValue = setting.templateSettingValues?.find(
|
||
(value: NonNullable<TemplateSampleSetting["templateSettingValues"]>[number]) =>
|
||
value.unitId != null,
|
||
);
|
||
const defaultValue = setting.templateSettingValues?.find(
|
||
(value: NonNullable<TemplateSampleSetting["templateSettingValues"]>[number]) =>
|
||
value.unitId == null,
|
||
);
|
||
loadedResourceIds[setting.id] =
|
||
setting.resourceId ??
|
||
unitValue?.resourceId ??
|
||
defaultValue?.resourceId ??
|
||
null;
|
||
});
|
||
setResourceIds(loadedResourceIds);
|
||
|
||
// Initialize all settings as using defaults
|
||
const defaultMap: Record<string, boolean> = {};
|
||
initializedSettings.forEach((setting) => {
|
||
if (setting) {
|
||
defaultMap[setting.id] = true;
|
||
}
|
||
});
|
||
setUseDefaultSettings(defaultMap);
|
||
setCustomSettings({});
|
||
} catch (err) {
|
||
console.error("Error loading settings:", err);
|
||
setError(t("error.loadingSettings", "Error loading style settings"));
|
||
toast.error(t("error.loadingSettings", "Error loading style settings"));
|
||
} finally {
|
||
setSettingsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResourceIdChange = useCallback(
|
||
(settingId: string, resourceId: string | null) => {
|
||
setResourceIds((previous) => ({ ...previous, [settingId]: resourceId }));
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleGenerateSample = async () => {
|
||
if (!templateId) return;
|
||
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// Build header object - use first header if available
|
||
let headerObj: any = undefined;
|
||
if (selectedHeaderId) {
|
||
const selectedHeaderData = headers.find(
|
||
(h) => h.id === selectedHeaderId,
|
||
);
|
||
if (selectedHeaderData?.fileInfo) {
|
||
headerObj = selectedHeaderData.fileInfo;
|
||
}
|
||
} else if (headers.length > 0) {
|
||
// Use first header as default
|
||
headerObj = headers[0].fileInfo;
|
||
}
|
||
|
||
// Build footer object - use first footer if available
|
||
let footerObj: any = undefined;
|
||
if (selectedFooterId) {
|
||
const selectedFooterData = footers.find(
|
||
(f) => f.id === selectedFooterId,
|
||
);
|
||
if (selectedFooterData?.fileInfo) {
|
||
footerObj = selectedFooterData.fileInfo;
|
||
}
|
||
} else if (footers.length > 0) {
|
||
// Use first footer as default
|
||
footerObj = footers[0].fileInfo;
|
||
}
|
||
|
||
// Add unitId and header/footer to formData
|
||
const dataWithUnitIdAndHeaderFooter = {
|
||
...formData,
|
||
unitId: unitId || "",
|
||
...(headerObj && { header: headerObj }),
|
||
...(footerObj && { footer: footerObj }),
|
||
};
|
||
|
||
const resolveSettingValue = (
|
||
setting: TemplateSampleSetting,
|
||
): Record<string, unknown> => {
|
||
if (isLetterLabelsSetting(setting.code)) {
|
||
return normalizeLetterLabelsValue(setting.value);
|
||
}
|
||
if (isPageMarginSetting(setting.code)) {
|
||
return normalizePageMarginValue(setting.value);
|
||
}
|
||
|
||
const cssString =
|
||
typeof setting.value === "string" ? setting.value : "";
|
||
const valueObj = cssStringToObject(cssString);
|
||
|
||
if (valueObj["list-style"] === "disclosure-closed") {
|
||
const svgTriangle = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4 2v12l10-6z"/></svg>`;
|
||
valueObj["list-style-image"] =
|
||
`url('data:image/svg+xml;base64,${btoa(svgTriangle)}')`;
|
||
}
|
||
|
||
if (
|
||
valueObj["list-style-image"] === "null" &&
|
||
valueObj["list-style"] === "disclosure-closed"
|
||
) {
|
||
const svgTriangle = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4 2v12l10-6z"/></svg>`;
|
||
valueObj["list-style-image"] =
|
||
`url('data:image/svg+xml;base64,${btoa(svgTriangle)}')`;
|
||
}
|
||
|
||
return valueObj;
|
||
};
|
||
|
||
const payload = buildTemplateSamplePayload({
|
||
data: dataWithUnitIdAndHeaderFooter,
|
||
settings,
|
||
resourceIds,
|
||
bulletResources,
|
||
resolveSettingValue,
|
||
});
|
||
|
||
const settingsForAPI = payload.settings ?? [];
|
||
|
||
const valuesToSave = settingsForAPI
|
||
.map((setting) => {
|
||
const valueObj = setting.value as Record<string, string>;
|
||
|
||
if (!valueObj || Object.keys(valueObj).length === 0) return null;
|
||
|
||
const resourceId = setting.resourceId ?? null;
|
||
|
||
return {
|
||
templateSettingId: setting.id!,
|
||
resourceId,
|
||
value: valueObj,
|
||
};
|
||
})
|
||
.filter((item) => item !== null) as Array<{
|
||
templateSettingId: string;
|
||
resourceId: string | null;
|
||
value: Record<string, string>;
|
||
}>;
|
||
console.log("Values to save:", valuesToSave);
|
||
const response = await TemplateService.generateSampleTemplate(
|
||
templateId,
|
||
payload,
|
||
);
|
||
|
||
if (response.pdf) {
|
||
console.log("PDF URL set:", response.pdf);
|
||
onGeneratePreview(response.pdf, valuesToSave);
|
||
} else {
|
||
setError(t("error.noPdfInResponse", "No PDF in response"));
|
||
}
|
||
} catch (err: any) {
|
||
const errorMessage =
|
||
err?.response?.data?.message ||
|
||
err?.message ||
|
||
t("error.generatingSample", "Error generating sample");
|
||
console.error("Error generating sample:", err);
|
||
setError(errorMessage);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleContentChange = useCallback((field: string, value: any) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
[field]: value,
|
||
},
|
||
}));
|
||
}, []);
|
||
|
||
// Debounce timer for settings changes
|
||
const settingsDebounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
const handleSettingChange = useCallback(
|
||
(index: number, cssString: string) => {
|
||
console.log("handleSettingChange called", { index, cssString });
|
||
|
||
// ✅ Update settings immediately using functional update
|
||
setSettings((prevSettings) => {
|
||
const settingId = prevSettings[index]?.id;
|
||
if (!settingId) {
|
||
console.log("No settingId found at index", index);
|
||
return prevSettings;
|
||
}
|
||
|
||
// Create a new array with a new object for the changed item (avoid mutation)
|
||
const newSettings = prevSettings.map((s, i) =>
|
||
i === index ? { ...s, value: cssString } : s,
|
||
);
|
||
|
||
// Update related state immediately
|
||
setUseDefaultSettings((prev) => ({
|
||
...prev,
|
||
[settingId]: false,
|
||
}));
|
||
|
||
setCustomSettings((prev) => ({
|
||
...prev,
|
||
[settingId]: cssString,
|
||
}));
|
||
|
||
return newSettings;
|
||
});
|
||
},
|
||
[], // ✅ No dependencies - uses functional updates to avoid stale closures
|
||
);
|
||
|
||
const handleToggleDefault = useCallback(
|
||
(settingId: string) => {
|
||
setUseDefaultSettings((prev) => {
|
||
const newValue = !prev[settingId];
|
||
|
||
if (newValue) {
|
||
// Switching back to default - remove from custom
|
||
setCustomSettings((prevCustom) => {
|
||
const newCustom = { ...prevCustom };
|
||
delete newCustom[settingId];
|
||
return newCustom;
|
||
});
|
||
|
||
// Reset the setting value to default
|
||
const settingIndex = settings.findIndex((s) => s.id === settingId);
|
||
if (settingIndex >= 0) {
|
||
const defaultValue =
|
||
(settings[settingIndex] as any).templateSettingValues?.[0]
|
||
?.value || {};
|
||
let cssString = "";
|
||
if (typeof defaultValue === "object" && defaultValue !== null) {
|
||
cssString = Object.entries(defaultValue)
|
||
.map(([key, value]) => `${key}: ${value};`)
|
||
.join(" ");
|
||
}
|
||
setSettings((prev) => {
|
||
const newSettings = [...prev];
|
||
newSettings[settingIndex].value = cssString;
|
||
return newSettings;
|
||
});
|
||
}
|
||
}
|
||
|
||
return { ...prev, [settingId]: newValue };
|
||
});
|
||
},
|
||
[settings],
|
||
);
|
||
|
||
const handleAddCollaborator = () => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
collaborators: [...prev.collaborators, { am: "", en: "" }],
|
||
}));
|
||
};
|
||
|
||
const handleCollaboratorChange = (
|
||
index: number,
|
||
lang: "am" | "en",
|
||
value: string,
|
||
) => {
|
||
const newCollaborators = [...formData.collaborators];
|
||
newCollaborators[index][lang] = value;
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
collaborators: newCollaborators,
|
||
}));
|
||
};
|
||
|
||
const handleSelectCollaborator = (employeeId: string) => {
|
||
const employee = employees.find(
|
||
(emp: any) => emp.employeePositions?.[0]?.id === employeeId,
|
||
);
|
||
if (employee && !selectedCollaborators.includes(employeeId)) {
|
||
const newCollaborators = [
|
||
...formData.collaborators,
|
||
{
|
||
am: employee.user?.name?.am || "",
|
||
en: employee.user?.name?.en || "",
|
||
},
|
||
];
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
collaborators: newCollaborators,
|
||
content: {
|
||
...prev.content,
|
||
from: newCollaborators.map((c) => c.en || c.am),
|
||
},
|
||
}));
|
||
setSelectedCollaborators([...selectedCollaborators, employeeId]);
|
||
}
|
||
};
|
||
|
||
const handleRemoveCollaborator = (index: number) => {
|
||
const newCollaborators = formData.collaborators.filter(
|
||
(_, i) => i !== index,
|
||
);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
collaborators: newCollaborators,
|
||
content: {
|
||
...prev.content,
|
||
from: newCollaborators.map((c) => c.en || c.am),
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handleSelectInternalCC = (departmentId: string) => {
|
||
const dept = departments.find((d: any) => d.id === departmentId);
|
||
if (dept && !selectedInternalCC.includes(departmentId)) {
|
||
const newInternalCC = [...selectedInternalCC, departmentId];
|
||
setSelectedInternalCC(newInternalCC);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
internalCC: newInternalCC.map((id) => {
|
||
const d = departments.find((d: any) => d.id === id);
|
||
return d?.name?.en || d?.name?.am || id;
|
||
}),
|
||
},
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleRemoveInternalCC = (departmentId: string) => {
|
||
const newInternalCC = selectedInternalCC.filter(
|
||
(id) => id !== departmentId,
|
||
);
|
||
setSelectedInternalCC(newInternalCC);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
internalCC: newInternalCC.map((id) => {
|
||
const d = departments.find((d: any) => d.id === id);
|
||
return d?.name?.en || d?.name?.am || id;
|
||
}),
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handleSelectExternalCC = (unitName: string) => {
|
||
if (!selectedExternalCC.includes(unitName)) {
|
||
const newExternalCC = [...selectedExternalCC, unitName];
|
||
setSelectedExternalCC(newExternalCC);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
externalCC: newExternalCC,
|
||
},
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleRemoveExternalCC = (unitName: string) => {
|
||
const newExternalCC = selectedExternalCC.filter((u) => u !== unitName);
|
||
setSelectedExternalCC(newExternalCC);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
externalCC: newExternalCC,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handleSelectRecipient = (recipientId: string) => {
|
||
if (!selectedRecipients.includes(recipientId)) {
|
||
const newRecipients = [...selectedRecipients, recipientId];
|
||
setSelectedRecipients(newRecipients);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
to: newRecipients.map((id) => {
|
||
const dept = departments.find((d: any) => d.id === id);
|
||
const emp = employees.find(
|
||
(e: any) => e.employeePositions?.[0]?.id === id,
|
||
);
|
||
return (
|
||
dept?.name?.en ||
|
||
dept?.name?.am ||
|
||
emp?.user?.name?.en ||
|
||
emp?.user?.name?.am ||
|
||
id
|
||
);
|
||
}),
|
||
},
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleRemoveRecipient = (recipientId: string) => {
|
||
const newRecipients = selectedRecipients.filter((id) => id !== recipientId);
|
||
setSelectedRecipients(newRecipients);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
content: {
|
||
...prev.content,
|
||
to: newRecipients.map((id) => {
|
||
const dept = departments.find((d: any) => d.id === id);
|
||
const emp = employees.find(
|
||
(e: any) => e.employeePositions?.[0]?.id === id,
|
||
);
|
||
return (
|
||
dept?.name?.en ||
|
||
dept?.name?.am ||
|
||
emp?.user?.name?.en ||
|
||
emp?.user?.name?.am ||
|
||
id
|
||
);
|
||
}),
|
||
},
|
||
}));
|
||
};
|
||
|
||
if (loading && !template) {
|
||
return (
|
||
<div className="flex items-center justify-center h-96">
|
||
<Loader className="h-8 w-8 animate-spin text-purple-600" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Helper to get settings for a specific section
|
||
const getSettingsBySection = (sectionCode: string) => {
|
||
return settings.filter((s) => s.code.includes(sectionCode));
|
||
};
|
||
|
||
// Match any of the provided aliases so API code variations can still show in UI.
|
||
const getSettingsByAliases = (aliases: string[]) => {
|
||
const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
|
||
return settings.filter((setting) => {
|
||
const code = setting.code.toLowerCase();
|
||
return normalizedAliases.some((alias) => code.includes(alias));
|
||
});
|
||
};
|
||
|
||
const renderSettingsEditors = (matchedSettings: TemplateSampleSetting[]) => {
|
||
if (matchedSettings.length === 0) return null;
|
||
|
||
return matchedSettings.map((setting) => {
|
||
const originalIndex = settings.findIndex((s) => s.code === setting.code);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
onListStyleResourceChange={(resourceId) =>
|
||
handleResourceIdChange(setting.id, resourceId)
|
||
}
|
||
/>
|
||
);
|
||
});
|
||
};
|
||
console.log("form data", formData);
|
||
console.log("settings", settings);
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-4">
|
||
<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>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||
{t("template.sample", "Template Sample")}
|
||
</h1>
|
||
{template && (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
{template.name?.en || template.name?.am}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{/* Two-column layout - grid with proper alignment */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Left column: collapsible sections (2 columns wide) */}
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{/* Basic Information (unchanged) */}
|
||
<CollapsibleSection
|
||
title={t("template.basicInfo", "Basic Information")}
|
||
isExpanded={expandedSections.basicInfo}
|
||
onToggle={() => toggleSection("basicInfo")}>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.recordType", "Record Type")}
|
||
</label>
|
||
<select
|
||
value={formData.recordType}
|
||
onChange={(e) =>
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
recordType: e.target.value as "external" | "internal",
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="external">
|
||
{t("template.external", "External")}
|
||
</option>
|
||
<option value="internal">
|
||
{t("template.internal", "Internal")}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.preferredLanguage", "Preferred Language")}
|
||
</label>
|
||
<select
|
||
value={formData.preferredLanguage}
|
||
onChange={(e) =>
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
preferredLanguage: e.target.value as "am" | "en",
|
||
}))
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="am">{t("language.amharic", "Amharic")}</option>
|
||
<option value="en">{t("language.english", "English")}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Header & Footer Section */}
|
||
<CollapsibleSection
|
||
title={t("template.headerFooter", "Header & Footer")}
|
||
isExpanded={expandedSections.headerFooter}
|
||
onToggle={() => toggleSection("headerFooter")}>
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("addRecord.Header", "Header")}
|
||
</label>
|
||
<select
|
||
value={selectedHeaderId}
|
||
onChange={(e) => setSelectedHeaderId(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="">
|
||
{headers?.length
|
||
? t("addRecord.Select Header", "Select Header")
|
||
: t(
|
||
"addRecord.Loading Headers...",
|
||
"Loading Headers...",
|
||
)}
|
||
</option>
|
||
{headers?.map((header) => (
|
||
<option key={header.id} value={header.id}>
|
||
{header.name?.en || header.name?.am}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("addRecord.Footer", "Footer")}
|
||
</label>
|
||
<select
|
||
value={selectedFooterId}
|
||
onChange={(e) => setSelectedFooterId(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="">
|
||
{footers?.length
|
||
? t("addRecord.Select Footer", "Select Footer")
|
||
: t(
|
||
"addRecord.Loading Footers...",
|
||
"Loading Footers...",
|
||
)}
|
||
</option>
|
||
{footers?.map((footer) => (
|
||
<option key={footer.id} value={footer.id}>
|
||
{footer.name?.en || footer.name?.am}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedHeader && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||
<p className="text-sm font-medium text-gray-600 dark:text-gray-300 mb-2">
|
||
{t("addRecord.Header Preview", "Header Preview")}
|
||
</p>
|
||
<div
|
||
dangerouslySetInnerHTML={{
|
||
__html: sanitizeHtml(selectedHeader),
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Header Style Settings */}
|
||
{getSettingsBySection("header").length > 0 && (
|
||
<div className="mt-4">
|
||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||
{t("template.headerStyleSettings", "Header Style Settings")}
|
||
</h3>
|
||
{getSettingsBySection("header").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{selectedFooter && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||
<p className="text-sm font-medium text-gray-600 dark:text-gray-300 mb-2">
|
||
{t("addRecord.Footer Preview", "Footer Preview")}
|
||
</p>
|
||
<div
|
||
dangerouslySetInnerHTML={{
|
||
__html: sanitizeHtml(selectedFooter),
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer Style Settings */}
|
||
{getSettingsBySection("footer").length > 0 && (
|
||
<div className="mt-4">
|
||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||
{t("template.footerStyleSettings", "Footer Style Settings")}
|
||
</h3>
|
||
{getSettingsBySection("footer").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Content Section */}
|
||
<CollapsibleSection
|
||
title={t("template.content", "Content")}
|
||
isExpanded={expandedSections.content}
|
||
onToggle={() => toggleSection("content")}>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.subject", "Subject")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.subject}
|
||
onChange={(e) =>
|
||
handleContentChange("subject", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* Subject Style Settings */}
|
||
{getSettingsBySection("subject").length > 0 && (
|
||
<div className="mt-4">
|
||
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||
{t(
|
||
"template.subjectStyleSettings",
|
||
"Subject Style Settings",
|
||
)}
|
||
</h4>
|
||
{getSettingsBySection("subject").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.date", "Date")}
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={formData.content.date}
|
||
onChange={(e) => handleContentChange("date", e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.body", "Body")}
|
||
</label>
|
||
<textarea
|
||
value={formData.content.body}
|
||
onChange={(e) => handleContentChange("body", e.target.value)}
|
||
rows={6}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* Body Style Settings */}
|
||
{getSettingsBySection("body").length > 0 && (
|
||
<div className="mt-4">
|
||
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||
{t("template.bodyStyleSettings", "Body Style Settings")}
|
||
</h4>
|
||
{getSettingsBySection("body").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.sincerelyText", "Sincerely Text")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.sincerelyText}
|
||
onChange={(e) =>
|
||
handleContentChange("sincerelyText", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.prefix", "Prefix")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.prefix}
|
||
onChange={(e) =>
|
||
handleContentChange("prefix", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* Prefix Style Settings */}
|
||
{getSettingsBySection("prefix").length > 0 && (
|
||
<div className="mt-3">
|
||
<h5 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.prefixStyleSettings", "Prefix Style")}
|
||
</h5>
|
||
{getSettingsBySection("prefix").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.suffix", "Suffix")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.suffix}
|
||
onChange={(e) =>
|
||
handleContentChange("suffix", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* Suffix Style Settings */}
|
||
{getSettingsBySection("suffix").length > 0 && (
|
||
<div className="mt-3">
|
||
<h5 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.suffixStyleSettings", "Suffix Style")}
|
||
</h5>
|
||
{getSettingsBySection("suffix").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.prefixCC", "Prefix CC")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.prefixCC}
|
||
onChange={(e) =>
|
||
handleContentChange("prefixCC", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* CC Prefix Style Settings */}
|
||
{getSettingsBySection("cc-prefix").length > 0 && (
|
||
<div className="mt-3">
|
||
<h5 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.ccPrefixStyleSettings", "CC Prefix Style")}
|
||
</h5>
|
||
{getSettingsBySection("cc-prefix").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.suffixCC", "Suffix CC")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.suffixCC}
|
||
onChange={(e) =>
|
||
handleContentChange("suffixCC", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
|
||
{/* CC Suffix Style Settings */}
|
||
{getSettingsBySection("cc-suffix").length > 0 && (
|
||
<div className="mt-3">
|
||
<h5 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.ccSuffixStyleSettings", "CC Suffix Style")}
|
||
</h5>
|
||
{getSettingsBySection("cc-suffix").map((setting) => {
|
||
const originalIndex = settings.findIndex(
|
||
(s) => s.code === setting.code,
|
||
);
|
||
return (
|
||
<StyleSettingsEditor
|
||
key={setting.code}
|
||
setting={setting}
|
||
index={originalIndex}
|
||
onChange={handleSettingChange}
|
||
isDefault={useDefaultSettings[setting.id] ?? true}
|
||
onToggleDefault={handleToggleDefault}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.content.isWithDelegateSignature}
|
||
onChange={(e) =>
|
||
handleContentChange(
|
||
"isWithDelegateSignature",
|
||
e.target.checked,
|
||
)
|
||
}
|
||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800"
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
{t(
|
||
"template.withDelegateSignature",
|
||
"With Delegate Signature",
|
||
)}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{formData.content.isWithDelegateSignature && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("template.delegatorName", "Delegator Name")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.content.delegatorName}
|
||
onChange={(e) =>
|
||
handleContentChange("delegatorName", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Collaborators (unchanged) */}
|
||
<CollapsibleSection
|
||
title={t("template.collaborators", "Collaborators")}
|
||
isExpanded={expandedSections.collaborators}
|
||
onToggle={() => toggleSection("collaborators")}>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex gap-2">
|
||
<select
|
||
onChange={(e) => {
|
||
if (e.target.value) {
|
||
handleSelectCollaborator(e.target.value);
|
||
e.target.value = "";
|
||
}
|
||
}}
|
||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="">
|
||
{t("common.selectEmployee", "Select Employee")}
|
||
</option>
|
||
{employees.map((emp: any) => (
|
||
<option
|
||
key={emp.employeePositions?.[0]?.id}
|
||
value={emp.employeePositions?.[0]?.id}>
|
||
{emp.user?.name?.en || emp.user?.name?.am}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
onClick={handleAddCollaborator}
|
||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-1">
|
||
<Plus className="h-4 w-4" />
|
||
{t("common.add", "Add")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{formData.collaborators.map((collab, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="grid grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg relative">
|
||
<button
|
||
onClick={() => handleRemoveCollaborator(idx)}
|
||
className="absolute top-2 right-2 p-1 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors">
|
||
<X className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||
</button>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
||
{t("language.amharic", "Amharic")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={collab.am}
|
||
onChange={(e) =>
|
||
handleCollaboratorChange(idx, "am", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm dark:bg-gray-600 dark:text-gray-200"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
||
{t("language.english", "English")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={collab.en}
|
||
onChange={(e) =>
|
||
handleCollaboratorChange(idx, "en", e.target.value)
|
||
}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm dark:bg-gray-600 dark:text-gray-200"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Recipients & CC (unchanged) */}
|
||
<CollapsibleSection
|
||
title={t("template.recipientsAndCC", "Recipients & CC")}
|
||
isExpanded={expandedSections.recipients}
|
||
onToggle={() => toggleSection("recipients")}>
|
||
<div className="space-y-6">
|
||
{/* Internal CC */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-800 dark:text-gray-200 mb-2">
|
||
{t("template.internalCC", "Internal CC")}
|
||
</h3>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<select
|
||
onChange={(e) => {
|
||
if (e.target.value) {
|
||
handleSelectInternalCC(e.target.value);
|
||
e.target.value = "";
|
||
}
|
||
}}
|
||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="">
|
||
{t("common.selectDepartment", "Select Department")}
|
||
</option>
|
||
{departments.map((dept: any) => (
|
||
<option key={dept.id} value={dept.id}>
|
||
{dept.name?.en || dept.name?.am}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedInternalCC.map((deptId) => {
|
||
const dept = departments.find((d: any) => d.id === deptId);
|
||
return (
|
||
<div
|
||
key={deptId}
|
||
className="flex items-center gap-2 px-3 py-1 bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 rounded-full text-sm">
|
||
<span>{dept?.name?.en || dept?.name?.am}</span>
|
||
<button
|
||
onClick={() => handleRemoveInternalCC(deptId)}
|
||
className="hover:text-purple-900 dark:hover:text-purple-100">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{renderSettingsEditors(
|
||
getSettingsByAliases([
|
||
"internal-cc",
|
||
"internalcc",
|
||
"cc-internal",
|
||
"ccinternal",
|
||
]),
|
||
)}
|
||
</div>
|
||
|
||
{/* External CC */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-800 dark:text-gray-200 mb-2">
|
||
{t("template.externalCC", "External CC")}
|
||
</h3>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<input
|
||
type="text"
|
||
placeholder={t("common.enterName", "Enter name")}
|
||
onKeyPress={(e) => {
|
||
if (e.key === "Enter" && e.currentTarget.value) {
|
||
handleSelectExternalCC(e.currentTarget.value);
|
||
e.currentTarget.value = "";
|
||
}
|
||
}}
|
||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedExternalCC.map((unitName, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="flex items-center gap-2 px-3 py-1 bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 rounded-full text-sm">
|
||
<span>{unitName}</span>
|
||
<button
|
||
onClick={() => handleRemoveExternalCC(unitName)}
|
||
className="hover:text-primary-900 dark:hover:text-primary-100">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{renderSettingsEditors(
|
||
getSettingsByAliases([
|
||
"external-cc",
|
||
"externalcc",
|
||
"cc-external",
|
||
"ccexternal",
|
||
]),
|
||
)}
|
||
</div>
|
||
|
||
{/* Shared CC Receiver Style */}
|
||
<div>
|
||
{renderSettingsEditors(getSettingsByAliases(["cc-receiver"]))}
|
||
</div>
|
||
|
||
{/* Recipients */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-800 dark:text-gray-200 mb-2">
|
||
{t("template.recipients", "Recipients")}
|
||
</h3>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<select
|
||
onChange={(e) => {
|
||
if (e.target.value) {
|
||
handleSelectRecipient(e.target.value);
|
||
e.target.value = "";
|
||
}
|
||
}}
|
||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||
<option value="">
|
||
{t("common.selectRecipient", "Select Recipient")}
|
||
</option>
|
||
{departments.map((dept: any) => (
|
||
<option key={dept.id} value={dept.id}>
|
||
{dept.name?.en || dept.name?.am}
|
||
</option>
|
||
))}
|
||
{employees.map((emp: any) => (
|
||
<option
|
||
key={emp.employeePositions?.[0]?.id}
|
||
value={emp.employeePositions?.[0]?.id}>
|
||
{emp.user?.name?.en || emp.user?.name?.am}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedRecipients.map((recipientId) => {
|
||
const dept = departments.find(
|
||
(d: any) => d.id === recipientId,
|
||
);
|
||
const emp = employees.find(
|
||
(e: any) => e.employeePositions?.[0]?.id === recipientId,
|
||
);
|
||
const name =
|
||
dept?.name?.en ||
|
||
dept?.name?.am ||
|
||
emp?.user?.name?.en ||
|
||
emp?.user?.name?.am;
|
||
return (
|
||
<div
|
||
key={recipientId}
|
||
className="flex items-center gap-2 px-3 py-1 bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 rounded-full text-sm">
|
||
<span>{name}</span>
|
||
<button
|
||
onClick={() => handleRemoveRecipient(recipientId)}
|
||
className="hover:text-primary-900 dark:hover:text-primary-100">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{renderSettingsEditors(
|
||
getSettingsByAliases([
|
||
"recipient",
|
||
"recipients",
|
||
"to",
|
||
"receiver",
|
||
]),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Generate Button */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||
<button
|
||
onClick={handleGenerateSample}
|
||
disabled={loading || settingsLoading}
|
||
className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 dark:disabled:bg-gray-600 transition-colors font-medium flex items-center justify-center gap-2">
|
||
{loading || settingsLoading ? (
|
||
<>
|
||
<Loader className="h-4 w-4 animate-spin" />
|
||
{t("common.generating", "Generating...")}
|
||
</>
|
||
) : (
|
||
t("template.generateSample", "Generate Sample")
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right column: live preview - scrolls with form */}
|
||
<div className="lg:col-span-1">
|
||
<div>
|
||
{settingsLoading ? (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 flex items-center justify-center min-h-96">
|
||
<Loader className="h-8 w-8 animate-spin text-purple-600" />
|
||
</div>
|
||
) : (
|
||
<TemplateLivePreview
|
||
formData={formData}
|
||
settings={settings}
|
||
selectedHeader={selectedHeader}
|
||
selectedFooter={selectedFooter}
|
||
templateName={template?.name?.en || template?.name?.am}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|