user management ui

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import { Eye } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import type { AllRecord } from "@/user-management/all-records/types";
const displayValue = (value?: string | { en?: string; am?: string }) =>
typeof value === "string" ? value : value?.en || value?.am || "—";
const AllRecordsTable = ({ records }: { records: AllRecord[] }) => {
const navigate = useNavigate();
return (
<div className="overflow-x-auto rounded-lg border dark:border-slate-700">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500 dark:bg-slate-800">
<tr>
<th className="px-5 py-3">Reference</th>
<th className="px-5 py-3">Subject</th>
<th className="px-5 py-3">Record type</th>
<th className="px-5 py-3">Sender</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Created</th>
<th className="px-5 py-3">Action</th>
</tr>
</thead>
<tbody className="divide-y bg-white dark:divide-slate-700 dark:bg-slate-900">
{records.map((record) => (
<tr key={record.id}>
<td className="px-5 py-4 font-medium">
{record.referenceNumber || "—"}
</td>
<td className="max-w-xs truncate px-5 py-4">
{displayValue(record.subject)}
</td>
<td className="px-5 py-4">{displayValue(record.recordType)}</td>
<td className="px-5 py-4">{displayValue(record.sender)}</td>
<td className="px-5 py-4 capitalize">{record.status || "—"}</td>
<td className="px-5 py-4">
{record.createdAt
? new Date(record.createdAt).toLocaleDateString()
: "—"}
</td>
<td className="px-5 py-4">
<Button
size="sm"
variant="outline"
className="gap-2"
onClick={() =>
navigate(`/user-management/all-records/${record.id}`)
}
>
<Eye className="h-4 w-4" /> View Details
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AllRecordsTable;

View File

@@ -0,0 +1,19 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
fetchAllRecords,
fetchRecordDetails,
} from "@/user-management/all-records/services/allRecordsService";
import type { AllRecordsParams } from "@/user-management/all-records/types";
export const useAllRecords = () =>
useMutation({
mutationKey: ["all-records"],
mutationFn: (params: AllRecordsParams) => fetchAllRecords(params),
});
export const useRecordDetails = (id?: string) =>
useQuery({
queryKey: ["all-records", "details", id],
queryFn: () => fetchRecordDetails(id!),
enabled: Boolean(id),
});

View File

@@ -0,0 +1,73 @@
import { ArrowLeft, CalendarDays, FileText } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { useRecordDetails } from "@/user-management/all-records/hooks/useAllRecords";
const displayValue = (value?: string | { en?: string; am?: string }) =>
typeof value === "string" ? value : value?.en || value?.am || "—";
const AllRecordDetailsPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const recordQuery = useRecordDetails(id);
if (recordQuery.isLoading) {
return <div className="m-6 h-64 animate-pulse rounded-xl bg-slate-200 dark:bg-slate-800" />;
}
if (recordQuery.isError || !recordQuery.data) {
return (
<div className="m-6 rounded-lg border border-red-200 bg-red-50 p-8 text-center">
<p className="text-red-700">Unable to load this record.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
const record = recordQuery.data;
const fields = [
["Reference", record.referenceNumber || "—"],
["Subject", displayValue(record.subject)],
["Record type", displayValue(record.recordType)],
["Sender", displayValue(record.sender)],
["Status", record.status || "—"],
["Description", record.description || "—"],
];
return (
<div className="w-full space-y-6 p-4 sm:p-6">
<Button variant="ghost" className="gap-2" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" /> Back to all records
</Button>
<article className="rounded-xl border bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<header className="mb-6 border-b pb-5 dark:border-slate-700">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<FileText className="h-6 w-6 text-primary" /> Record Details
</h1>
<p className="mt-1 text-sm text-slate-500">Record ID: {record.id}</p>
</header>
<dl className="grid gap-5 sm:grid-cols-2">
{fields.map(([label, value]) => (
<div key={label}>
<dt className="text-sm text-slate-500">{label}</dt>
<dd className="mt-1 font-medium">{value}</dd>
</div>
))}
<div>
<dt className="flex items-center gap-1 text-sm text-slate-500">
<CalendarDays className="h-4 w-4" /> Created
</dt>
<dd className="mt-1 font-medium">
{record.createdAt
? new Date(record.createdAt).toLocaleString()
: "—"}
</dd>
</div>
</dl>
</article>
</div>
);
};
export default AllRecordDetailsPage;

View File

@@ -0,0 +1,79 @@
import { useEffect, useMemo, useState } from "react";
import { Files, RefreshCw, Search } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import AllRecordsTable from "@/user-management/all-records/components/AllRecordsTable";
import { useAllRecords } from "@/user-management/all-records/hooks/useAllRecords";
const AllRecordsPage = () => {
const [search, setSearch] = useState("");
const allRecordsMutation = useAllRecords();
useEffect(() => {
allRecordsMutation.mutate({ take: 50, skip: 0 });
}, []);
const records = useMemo(() => {
const items = allRecordsMutation.data?.items ?? [];
const term = search.trim().toLowerCase();
if (!term) return items;
return items.filter((record) =>
[
record.referenceNumber,
typeof record.subject === "string"
? record.subject
: record.subject?.en || record.subject?.am,
record.status,
].some((value) => value?.toLowerCase().includes(term)),
);
}, [allRecordsMutation.data?.items, search]);
return (
<div className="w-full space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Files className="h-6 w-6 text-primary" /> All Records
</h1>
<p className="mt-1 text-sm text-slate-500">
Browse and inspect all records.
</p>
</div>
<span className="w-fit rounded-full bg-slate-100 px-3 py-1 text-sm font-medium dark:bg-slate-800">
{allRecordsMutation.data?.count ?? 0} records
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by reference, subject, or status"
className="h-10 w-full rounded-md border bg-white pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-primary/30 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{allRecordsMutation.isPending ? (
<div className="h-64 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800" />
) : allRecordsMutation.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center">
<p className="text-sm text-red-700">The all-records API is not available yet.</p>
<Button
variant="outline"
className="mt-4 gap-2"
onClick={() => allRecordsMutation.mutate({ take: 50, skip: 0 })}
>
<RefreshCw className="h-4 w-4" /> Retry
</Button>
</div>
) : records.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center text-slate-500">
<Files className="mx-auto mb-3 h-9 w-9" />
<p className="font-medium">No records found</p>
</div>
) : (
<AllRecordsTable records={records} />
)}
</div>
);
};
export default AllRecordsPage;

View File

@@ -0,0 +1,27 @@
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import type {
AllRecord,
AllRecordsParams,
AllRecordsResponse,
} from "@/user-management/all-records/types";
// Provisional paths: update here when the backend contract is finalized.
const ALL_RECORDS_PATH = "/records/all";
export const fetchAllRecords = async (
params: AllRecordsParams,
): Promise<AllRecordsResponse> => {
const { data } = await recordAxiosInstance.get<AllRecordsResponse>(
ALL_RECORDS_PATH,
{ params, headers: withHeaders() },
);
return data;
};
export const fetchRecordDetails = async (id: string): Promise<AllRecord> => {
const { data } = await recordAxiosInstance.get<AllRecord>(`/records/${id}`, {
headers: withHeaders(),
});
return data;
};

View File

@@ -0,0 +1,27 @@
export interface LocalizedValue {
en?: string;
am?: string;
}
export interface AllRecord {
id: string;
referenceNumber?: string;
subject?: string | LocalizedValue;
recordType?: string | LocalizedValue;
status?: string;
sender?: string | LocalizedValue;
createdAt?: string;
updatedAt?: string;
description?: string;
}
export interface AllRecordsResponse {
count: number;
items: AllRecord[];
}
export interface AllRecordsParams {
take?: number;
skip?: number;
search?: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
const LOG_PREFIX = "[BulkUpload:Parser]";
type ParseLogPayload = Record<string, unknown>;
function log(stage: string, payload?: ParseLogPayload) {
if (payload) {
console.log(LOG_PREFIX, stage, payload);
return;
}
console.log(LOG_PREFIX, stage);
}
export function logBulkUploadParseStart(
mode: "new" | "update",
fileName: string,
) {
log("start", { mode, fileName });
}
export function logBulkUploadWorkbook(
mode: "new" | "update",
sheetNames: string[],
) {
log("workbook", { mode, sheetNames, sheetCount: sheetNames.length });
}
export function logBulkUploadSheetParse(
mode: "new" | "update",
sheetName: string,
details: ParseLogPayload,
) {
log(`sheet:${sheetName}`, { mode, ...details });
}
export function logBulkUploadValidation(
mode: "new" | "update",
details: ParseLogPayload,
) {
log("validation", { mode, ...details });
}
export function logBulkUploadParseSuccess(
mode: "new" | "update",
details: ParseLogPayload,
) {
log("success", { mode, ...details });
}
export function logBulkUploadParseError(
mode: "new" | "update",
error: unknown,
details?: ParseLogPayload,
) {
log("error", {
mode,
message: error instanceof Error ? error.message : String(error),
...details,
});
if (error instanceof Error && error.stack) {
console.error(LOG_PREFIX, "stack", error.stack);
}
}
export function logBulkUploadParseAbort(
mode: "new" | "update",
reason: string,
details?: ParseLogPayload,
) {
log("abort", { mode, reason, ...details });
}
export type DuplicateRow = {
Username?: string;
Email?: string;
PhoneNumber?: string;
Position?: string;
ReportsTo?: string;
__rowNumber?: number;
};
function normalizeDuplicateKey(
value: string,
field: keyof DuplicateRow,
): string {
const trimmed = value.trim();
if (field === "PhoneNumber") {
return trimmed.replace(/\D/g, "");
}
if (field === "Email" || field === "Username") {
return trimmed.toLowerCase();
}
return trimmed.toLowerCase();
}
function findDuplicateValues<T extends DuplicateRow>(
rows: T[],
field: keyof T,
): Array<{ value: string; count: number; rows: T[] }> {
const map = new Map<string, T[]>();
for (const row of rows) {
const raw = row[field];
const value = typeof raw === "string" ? raw.trim() : "";
if (!value) continue;
const key = normalizeDuplicateKey(value, field as keyof DuplicateRow);
if (!key) continue;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
return [...map.entries()]
.filter(([, matches]) => matches.length > 1)
.map(([, matches]) => ({
value: String(matches[0][field] ?? "").trim(),
count: matches.length,
rows: matches,
}));
}
export function collectBulkUploadDuplicateErrors(
users: DuplicateRow[],
): string[] {
const errors: string[] = [];
const checks: Array<{ field: keyof DuplicateRow; label: string }> = [
{ field: "Username", label: "Username" },
{ field: "Email", label: "Email" },
{ field: "PhoneNumber", label: "Phone number" },
];
for (const { field, label } of checks) {
const duplicates = findDuplicateValues(users, field);
for (const duplicate of duplicates) {
const rows = duplicate.rows
.map((row) =>
typeof row.__rowNumber === "number"
? `row ${row.__rowNumber}`
: String(row.Username ?? "unknown"),
)
.join(", ");
errors.push(
`Duplicate ${label} "${duplicate.value}" found in ${rows}`,
);
}
}
return errors;
}
export function logBulkUploadDuplicateCheck(
mode: "new" | "update",
positions: DuplicateRow[],
users: DuplicateRow[],
) {
const positionNameDups = findDuplicateValues(positions, "Position");
const usernameDups = findDuplicateValues(users, "Username");
const emailDups = findDuplicateValues(users, "Email");
const phoneDups = findDuplicateValues(users, "PhoneNumber");
const duplicateErrors = collectBulkUploadDuplicateErrors(users);
const invalidReportsTo = positions.filter((row) => {
const position = String(row.Position ?? "").trim();
const reportsTo = String(row.ReportsTo ?? "").trim();
return reportsTo && reportsTo === position;
});
const userPositions = new Set(
positions
.map((row) => String(row.Position ?? "").trim())
.filter(Boolean),
);
const missingPositionRefs = users.filter(
(row) =>
row.Position &&
!userPositions.has(String(row.Position).trim()),
);
log("duplicate-check", {
mode,
duplicateErrors,
positionNameDuplicates: positionNameDups.map((item) => ({
value: item.value,
count: item.count,
})),
usernameDuplicates: usernameDups.map((item) => ({
value: item.value,
count: item.count,
usernames: item.rows.map((row) => row.Username),
})),
emailDuplicates: emailDups.map((item) => ({
value: item.value,
count: item.count,
usernames: item.rows.map((row) => row.Username),
})),
phoneDuplicates: phoneDups.map((item) => ({
value: item.value,
count: item.count,
usernames: item.rows.map((row) => row.Username),
})),
selfReferencingReportsTo: invalidReportsTo.map((row) => row.Position),
usersWithUnknownPosition: missingPositionRefs.map((row) => ({
username: row.Username,
position: row.Position,
})),
});
}

View File

@@ -0,0 +1,98 @@
import { Button } from "@/shared/common/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { MainDTO } from "@/super-admin/dto/SuperAdminDto";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, MoreHorizontal } from "lucide-react";
import { TFunction } from "i18next";
import { NavigateFunction } from "react-router-dom";
export const MigratedDataColumnDefn = (
localizedName: (name?: { am?: string; en?: string }) => string,
t: TFunction,
navigate: NavigateFunction,
): ColumnDef<MainDTO>[] => {
const handleViewRecord = (recordId: string) => {
navigate(`/user-management/migrated-records-management/view/${recordId}`);
};
return [
{
id: "referenceNumber",
accessorKey: "record.referenceNumber",
header: t("migration.columns.referenceNumber"),
cell: ({ row }) => row.original?.record?.referenceNumber,
},
{
id: "letterNumber",
accessorKey: "record.letterNumber",
header: t("migration.columns.letterNumber"),
cell: ({ row }) => row.original?.record?.letterNumber,
},
{
id: "uploadedBy",
accessorKey: "record.metadata.uploadedBy.en",
header: t("migration.columns.uploadedBy"),
cell: ({ row }) =>
localizedName(
row.original?.record?.metadata?.uploadedBy ?? { en: "-", am: "-" },
),
},
{
id: "organization",
accessorKey: "record.metadata.organizationName.en",
header: t("migration.columns.organization"),
cell: ({ row }) =>
localizedName(
row.original?.record?.metadata?.organizationName ?? {
en: "-",
am: "-",
},
),
},
{
id: "dispatchedDate",
accessorKey: "record.dispatchedDate",
header: t("migration.columns.dispatchedDate"),
cell: ({ row }) =>
new Date(row.original?.record?.dispatchedDate).toLocaleString(),
},
{
id: "status",
accessorKey: "status",
header: t("migration.columns.status"),
cell: ({ row }) => row.original?.status,
},
{
id: "subject",
accessorKey: "record.content",
header: t("migration.columns.subject"),
cell: ({ row }) => row.original?.record?.content[0]?.subject || "-",
},
{
id: "actions",
cell: ({ row }) => {
const record = row.original.record;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewRecord(record.id)}>
<Eye className="h-4 w-4 mr-2" />
{t("migration.viewRecord")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
};

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useState } from "react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { useMigratedData } from "@/super-admin/hooks/useMigratedData";
import { MigratedDataColumnDefn } from "./MigratedDataColumnDefn";
import {
fetchValidOrganization,
ValidOrganizationDto,
} from "@/record-management/services/api/organizationService";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
SimpleTreeSelect,
TreeViewItemO,
} from "@/shared/common/form/fields/FormFields";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export default function MigratedDataManagement() {
const [pageIndex, setPageIndex] = useState(0);
const pageSize = 10;
const localizedName = useLocalizedName();
const { t } = useTranslation();
const navigate = useNavigate();
const { data: exteranlOrgsResponse, isLoading: loadingExternalOrgs } =
useQuery({
queryKey: ["organizations"],
queryFn: fetchValidOrganization,
staleTime: 5 * 60 * 1000,
});
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null);
const treeOrganizationsOptions: TreeViewItemO[] = useMemo(() => {
return (
exteranlOrgsResponse?.items.map((org: ValidOrganizationDto) => ({
id: org.id,
name: org.name,
hierarchyType: "organization",
value: org.units.length === 1 ? org.units[0].id : "",
children:
Array.isArray(org.units) && org.units.length > 0
? org.units.map((unit) => ({
id: unit.id,
name: unit.name,
hierarchyType: "unit",
value: unit.id,
children: [],
}))
: [],
})) || []
);
}, [exteranlOrgsResponse, localizedName]);
useEffect(() => {
if (!selectedUnitId) {
const firstValidUnit = exteranlOrgsResponse?.items
?.flatMap((org) => org.units)
.find((u) => u.id);
if (firstValidUnit) setSelectedUnitId(firstValidUnit.id);
}
}, [exteranlOrgsResponse, selectedUnitId]);
useEffect(() => {
if (selectedUnitId)
sessionStorage.setItem("selectedUnitId", selectedUnitId);
}, [selectedUnitId]);
useEffect(() => {
const saved = sessionStorage.getItem("selectedUnitId");
if (saved) setSelectedUnitId(saved);
}, []);
const { data: migratedResponse, isLoading: loadingMigratedData } =
useMigratedData(selectedUnitId ?? "", {
skip: pageIndex * pageSize,
take: pageSize,
orderBy: "migratedAt:DESC",
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
if (loadingExternalOrgs || loadingMigratedData) {
return <div>{t("common.loading")}</div>;
}
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="px-0">
<CardTitle className="text-xl font-semibold">
{t("migration.migratedData")}
</CardTitle>
{/* Export button hidden until export API is available
<Button onClick={exportMigrated}>
{isExporting ? t("migration.exporting") : t("migration.exportData")}
</Button>
*/}
</CardHeader>
<div className="mb-4 max-w-lg">
<SimpleTreeSelect
label={t("migration.selectUnit")}
options={treeOrganizationsOptions}
value={selectedUnitId}
onChange={setSelectedUnitId}
collapsible
localizedName={localizedName}
/>
</div>
<CardContent className="px-0">
<AdvancedTable
columns={MigratedDataColumnDefn(localizedName, t, navigate)}
data={migratedResponse?.items || []}
tableName={t("migration.migratedData")}
toolBarPosition="right"
itemCount={migratedResponse?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useNavigate, useParams } from "react-router-dom";
import { attachmentResponse } from "@/external-portal/hooks/useCreateExternalLetterRecord";
import ViewExternalRecord from "@/external-portal/components/ViewExternalRecord";
import { useTranslation } from "react-i18next";
const ViewMigratedDataPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
if (!id) {
return (
<div className="p-4 text-red-500">{t("migration.invalidRecordId")}</div>
);
}
const { isLoading, error } = attachmentResponse(id);
if (isLoading) {
return (
<div className="p-4 text-center">{t("migration.loadingRecord")}</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{t("migration.errorLoadingRecord", { message: error.message })}
</div>
);
}
return (
<ViewExternalRecord
itemId={id}
onBack={() => navigate("/user-management/migrated-records-management")}
/>
);
};
export default ViewMigratedDataPage;

View File

@@ -0,0 +1,221 @@
import React, { useState } from "react";
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
import { toast } from "sonner";
import { Check, X, Users, Search, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const ReviewAllPendingUsers = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const [searchTerm, setSearchTerm] = useState("");
const { pendingUsers, loadingPendingUsers, approveMutation, rejectMutation } =
usePendingUsers({ take: 50, skip: 0 });
const handleApprove = (id: string) => {
approveMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeApprovedSuccess")),
onError: (error) => handleError(error),
});
};
const handleReject = (id: string) => {
rejectMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeRejectedSuccess")),
onError: (error) => handleError(error),
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
// Integrate search with your API if needed
};
// Loading skeleton (full width)
if (loadingPendingUsers) {
return (
<div className="p-6 w-full">
{/* Header skeleton */}
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Search bar skeleton */}
<div className="flex gap-2 mb-6">
<div className="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
<div className="w-20 h-10 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Table skeleton */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[
t("pendingUsers.name"),
t("pendingUsers.email"),
t("pendingUsers.positions"),
t("pendingUsers.actions"),
].map((h) => (
<th
key={h}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr
key={i}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4">
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Empty state
if (!pendingUsers?.items?.length) {
return (
<div className="p-6 w-full">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center mt-6">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 p-3 rounded-full">
<Users className="h-8 w-8 text-primary-600" />
</div>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("pendingUsers.noPendingApprovals")}
</h3>
<p className="text-gray-500 mb-6">
{t("pendingUsers.allRequestsReviewed")}
</p>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<RefreshCw className="h-4 w-4 mr-2" />
{t("pendingUsers.refresh")}
</button>
</div>
</div>
);
}
return (
<div className="p-6 w-full">
{/* Header with count */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
{pendingUsers.count} {t("pendingUsers.pending")}
</span>
</div>
{/* Table - full width */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mt-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.name")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.email")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.positions")}
</th>
<th
scope="col"
className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{pendingUsers.items.map((emp: any) => (
<tr
key={emp.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{emp.name?.en}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.user?.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.employeePositions
?.map((pos: any) => pos.position?.name?.en)
.join(", ") || "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<div className="flex justify-center gap-2">
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={approveMutation.isPending}
onClick={() => handleApprove(emp.id)}>
<Check className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.approve")}
</button>
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={rejectMutation.isPending}
onClick={() => handleReject(emp.id)}>
<X className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.reject")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Footer note */}
<p className="text-xs text-gray-400 mt-4 text-center">
{t("pendingUsers.showingUpTo")}
</p>
</div>
);
};
export default ReviewAllPendingUsers;

View File

@@ -0,0 +1,85 @@
import { useLocalizedName } from "@/shared/common/localizedName";
import { ItemDTO, OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
import { ColumnDef } from "@tanstack/react-table";
import ArchivedUserActionsCell from "./ArchivedUsers/ArchivedUserActions";
import { format } from "date-fns/format";
import { t } from "i18next";
import { Badge } from "@/shared/common/ui/badge";
export const ArchivedUserColumnDefn: ColumnDef<ItemDTO>[] = [
{
accessorKey: "name",
header: () => t("setting.Name"),
cell: ({ row }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
// {
// accessorKey: "username",
// header: "Username",
// cell: ({ row }) => {
// const username = row.original.username;
// return <span>{username}</span>;
// },
// },
// {
// accessorKey: "createdAt",
// header: "Created At",
// cell: ({ row }) => {
// const date = row.original.createdAt;
// return <span>{format(new Date(date), "MMM d, yyyy HH:mm")}</span>;
// },
// },
{
accessorKey: "status",
header: () => t("userRecord.Status"),
cell: ({ row }) => {
const status = row.original?.status;
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "bg-red-100 text-red-600 hover:bg-red-100"; // red for inactive
case "active":
return "bg-primary-100 text-primary-600 hover:bg-primary-100"; // green for active
default:
return "bg-gray-100 text-gray-600 hover:bg-gray-100";
}
};
const getStatusText = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "InActive";
case "active":
return "Active";
default:
return "Not Available";
}
};
return (
<div>
<Badge
className={`${getStatusColor(
status
)} rounded-full px-6 py-1 font-medium`}>
{getStatusText(status)}
</Badge>
</div>
);
},
},
{
id: "actions",
header: () => t("userRecord.Actions"),
cell: ({ row }) => <ArchivedUserActionsCell row={row.original} />,
},
];

View File

@@ -0,0 +1,78 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface ActivateArchivedUserProps {
isOpen: boolean;
onClose: () => void;
userId: string;
}
const ActivateArchivedUser: React.FC<ActivateArchivedUserProps> = ({
isOpen,
onClose,
userId,
}) => {
const { activateUser, isActivatingUser } = useEmployeePositions();
const [isActivatedUser, setIsActivatedUser] = useState(false);
const {t} = useTranslation()
const {handleError } = useErrorHandler(t)
const onActviate = async () => {
try {
await activateUser({
payload: userId,
successCallback: () => {
onClose();
},
});
setIsActivatedUser(true);
}
catch (error) {
console.error("Error activating user:", error);
handleError(error);
}
}
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Activate Archived User?
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isActivatedUser}>
Cancel
</AlertDialogCancel>
<Button
variant="default"
onClick={onActviate}
disabled={isActivatedUser}>
{isActivatingUser && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Activate
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ActivateArchivedUser;

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/shared/common/ui/dropdown-menu"; // Adjust if needed
import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Trash, UserPlus } from "lucide-react"; // Adjust icons as needed
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
import { t } from "i18next";
import ActivateArchivedUser from "./ActivateArchivedUser";
type ActionsCellProps = {
row: ItemDTO;
};
const ArchivedUserActionsCell: React.FC<ActionsCellProps> = ({ row }) => {
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const handleEdit = () => {
navigate(`/user-management/archive/edit/${row?.userId}`);
};
const handleDelete = (e: Event) => {
e.preventDefault();
setDropdownOpen(false);
setShowDeleteDialog(true);
};
const handleActivateModal = () => {
setModalOpen(true);
};
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Edit")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleActivateModal}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<UserPlus className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Activate")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleDelete}
className="text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200">
<Trash className="mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{modalOpen && (
<ActivateArchivedUser
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
userId={row.id}
/>
)}
{/* {showDeleteDialog && (
<DeletePositionTypes
id={row.id}
onSuccess={() => setShowDeleteDialog(false)}
onClose={() => setShowDeleteDialog(false)}
/>
)} */}
</>
);
};
export default ArchivedUserActionsCell;

View File

@@ -0,0 +1,268 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Card } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { Plus, Trash2, Pencil } from "lucide-react";
import {
prefixSuffixService,
RemarkPayload,
} from "@/user-management/services/api/prefixSuffixService";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRemarkByUnitId } from "@/super-admin/hooks/useRemark";
import { t } from "i18next";
interface Props {
unitId: string;
}
interface RemarkDto {
id: string;
remark: string;
description?: string;
}
export const CommonRemarks = ({ unitId }: Props) => {
const [remark, setRemark] = useState("");
const [description, setDescription] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteRemarkId, setDeleteRemarkId] = useState<string | null>(null);
const [error, setError] = useState("");
const queryClient = useQueryClient();
const { data: remarksData, isLoading: isRemarkLoading } =
useRemarkByUnitId(unitId,{
skip: 0,
take: 300,
});
// Save/Edit Remark
const saveRemarkMutation = useMutation({
mutationFn: async () => {
if (!remark) throw new Error(t("contentManagement.remarkRequired"));
const payload: RemarkPayload = { unitId, remark, description };
if (editingId) {
return prefixSuffixService.editRemark(editingId, payload);
}
return prefixSuffixService.createRemark(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
resetForm();
setIsDialogOpen(false);
},
onError: (err: any) =>
setError(err.message || t("contentManagement.failed")),
});
// Delete Remark
const deleteRemarkMutation = useMutation({
mutationFn: (id: string) => prefixSuffixService.deleteRemarks(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
setIsDeleteDialogOpen(false);
setDeleteRemarkId(null);
},
onError: () => setError(t("contentManagement.failedToDelete")),
});
const resetForm = () => {
setRemark("");
setDescription("");
setEditingId(null);
setError("");
};
const handleEdit = (item: RemarkDto) => {
setEditingId(item.id);
setRemark(item.remark);
setDescription(item.description || "");
setIsDialogOpen(true);
};
const handleSubmit = () => saveRemarkMutation.mutate();
return (
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center">
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addRemark")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl w-full dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{editingId
? t("contentManagement.editRemark")
: t("contentManagement.addRemark")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col">
<Label htmlFor="remark" className="mb-1 dark:text-gray-200">
{t("header.Remark")}
</Label>
<Input
id="remark"
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder={t("header.Remark")}
disabled={isRemarkLoading}
className="w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<Label htmlFor="description" className="mb-1 dark:text-gray-200">
{t("header.Description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("header.Description")}
disabled={isRemarkLoading}
className="w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
resetForm();
setIsDialogOpen(false);
}}>
{t("common.Cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={isRemarkLoading} // ✅ access via mutation object
className="bg-primary hover:bg-primary/90 text-primary-foreground">
{isRemarkLoading
? t("contentManagement.saving")
: editingId
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Remarks Table */}
<h3 className="font-semibold text-md dark:text-white">
{t("contentManagement.commonRemarks")}
</h3>
{isRemarkLoading ? (
<div className="text-center py-4 dark:text-gray-400">{t("contentManagement.loading")}</div>
) : (
<div className="overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="border-b bg-gray-100 dark:bg-gray-700">
<th className="px-3 py-2 dark:text-white">#</th>
<th className="px-3 py-2 dark:text-white">{t("header.Remark")}</th>
<th className="px-3 py-2 dark:text-white">{t("header.Description")}</th>
<th className="px-3 py-2 dark:text-white">{t("userRecord.Actions")}</th>
</tr>
</thead>
<tbody>
{remarksData?.items.length ? (
remarksData.items.map((item: any, idx: any) => (
<tr key={item.id} className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-3 py-2 dark:text-gray-300">{idx + 1}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.remark}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.description}</td>
<td className="px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeleteRemarkId(item.id);
setIsDeleteDialogOpen(true);
}}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="text-center py-4 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (deleteRemarkMutation.isPending) return;
setIsDeleteDialogOpen(open);
if (!open) setDeleteRemarkId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.delete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.deleteMsg")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteRemarkMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteRemarkMutation.isPending || !deleteRemarkId}
onClick={() => {
if (!deleteRemarkId) return;
deleteRemarkMutation.mutate(deleteRemarkId);
}}
>
{deleteRemarkMutation.isPending
? t("common.deleting", "Deleting...")
: t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
};

View File

@@ -0,0 +1,508 @@
import SealCard from "./SealCard";
import LetterTemplatesTable from "./LetterTemplates/LetterTemplatesTable";
import RecentActivitiesCard from "./RecentActivitiesCard";
import { CommonRemarks } from "./CommonRemarks";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnit } from "@/user-management/hooks/useUnit";
import HeaderAndFooter from "./HeaderAndFooter";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { PrefixAndSuffix } from "./PrefixAndSuffixCard";
import { t } from "i18next";
import i18n from "@/i18n";
import AdminSetupAlert from "@/layout/components/AlertShow";
import RecordTagsManagement from "./record-tags/RecordTags";
import { TemplateSamplePage } from "@/user-management/TemplateSample/TemplateSamplePage";
import {
BookImage,
ClipboardType,
ChevronRight,
Menu,
PencilRuler,
SquareActivity,
Stamp,
Tag,
X,
Building,
ClipboardList,
FileText,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { useDebounce } from "../content/useDebounce";
import { SingleSelect } from "@/shared/common/ui/single-select";
export interface FilePreview {
type: "header" | "footer" | "seal";
url: string;
file: File;
uploadedAt: Date;
id: string | null;
}
export interface Activity {
id: string;
type: string;
description: string;
timestamp: Date;
}
interface MenuItem {
label: string;
icon: React.ReactNode;
element?: React.ReactNode;
}
const ContentManagement = () => {
const { user } = useAuth();
const lang = i18n.language;
const [searchParams, setSearchParams] = useSearchParams();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
const debouncedUnitId = useDebounce(selectedUnitId, 300);
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const { getAccessibleList } = useUnit();
const { data: unitsResponse, isLoading: unitsLoading } = organizationId
? getAccessibleList(organizationId, { take: 300, skip: 0 })
: { data: undefined, isLoading: false };
// Notify listeners (e.g. AdminSetupAlert) after the unit selection debounces.
useEffect(() => {
if (!debouncedUnitId) return;
window.dispatchEvent(
new CustomEvent("unitChanged", { detail: debouncedUnitId }),
);
}, [debouncedUnitId]);
const handleUnitChange = useCallback(
(value: string) => {
setSelectedUnitId(value);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("unit", value);
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const selectMenuItem = useCallback(
(index: number, label: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", label);
if (selectedUnitId) {
next.set("unit", selectedUnitId);
}
return next;
},
{ replace: true },
);
setIsMobileMenuOpen(false);
},
[selectedUnitId, setSearchParams],
);
// Close mobile menu when resizing to desktop
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 768) {
setIsMobileMenuOpen(false);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const unitFromUrl = searchParams.get("unit");
const items = unitsResponse?.data?.items;
if (
unitFromUrl &&
items?.some((unit: UnitDto) => unit.id === unitFromUrl) &&
unitFromUrl !== selectedUnitId
) {
setSelectedUnitId(unitFromUrl);
return;
}
if (
!unitsLoading &&
items?.length &&
!selectedUnitId &&
!unitFromUrl
) {
const firstUnitId = items[0].id;
setSelectedUnitId(firstUnitId);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("unit", firstUnitId);
if (!next.get("tab")) {
next.set("tab", "contentManagement.seal");
}
return next;
},
{ replace: true },
);
}
}, [
unitsLoading,
unitsResponse,
selectedUnitId,
searchParams,
setSearchParams,
]);
// Sidebar menu items with dynamic loading states
const menuItems: MenuItem[] = [
{
label: "contentManagement.seal",
icon: <Stamp className="h-5 w-5 flex-shrink-0" />,
element: <SealCard unitId={selectedUnitId} />,
},
{
label: "contentManagement.letterTemplate",
icon: <ClipboardList className="h-5 w-5 flex-shrink-0" />,
element: <LetterTemplatesTable unitId={selectedUnitId} />,
},
{
label: "recordTag.title",
icon: <Tag className="h-5 w-5 flex-shrink-0" />,
element: <RecordTagsManagement unitId={selectedUnitId} />,
},
{
label: "contentManagement.prefix",
icon: <PencilRuler className="h-5 w-5 flex-shrink-0" />,
element: (
<PrefixAndSuffix unitId={selectedUnitId} initialTab="internal" />
),
},
{
label: "contentManagement.commonRemarks",
icon: <ClipboardType className="h-5 w-5 flex-shrink-0" />,
element: <CommonRemarks unitId={selectedUnitId} />,
},
{
label: "contentManagement.headerAndFooter",
icon: <BookImage className="h-5 w-5 flex-shrink-0" />,
element: <HeaderAndFooter unitId={selectedUnitId} />,
},
{
label: "template.sample",
icon: <FileText className="h-5 w-5 flex-shrink-0" />,
element: <TemplateSamplePage unitId={selectedUnitId} />,
},
{
label: "dashboard.recentActivities",
icon: <SquareActivity className="h-5 w-5 flex-shrink-0" />,
element: <RecentActivitiesCard />,
},
];
const activeMenuIndex = useMemo(() => {
const tab = searchParams.get("tab");
if (!tab) return 0;
const index = menuItems.findIndex((item) => item.label === tab);
return index >= 0 ? index : 0;
}, [menuItems, searchParams]);
const activeMenuItem = menuItems[activeMenuIndex];
const isTemplateSampleActive = activeMenuItem?.label === "template.sample";
useEffect(() => {
if (!searchParams.get("tab") && menuItems.length > 0) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", menuItems[0].label);
return next;
},
{ replace: true },
);
}
}, [menuItems, searchParams, setSearchParams]);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100/30 dark:from-gray-900 dark:to-gray-800">
<div className="w-full h-full p-4 lg:p-6 space-y-6">
{/* Header Section */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
{t("organization.contentManagement")}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2 text-sm lg:text-base">
{t("organization.contentMsg")}
</p>
</div>
{/* Admin Alert */}
<div className="w-full lg:w-auto">
<AdminSetupAlert />
</div>
</div>
{/* Unit Selection Card */}
{unitsResponse?.data?.items?.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 lg:p-6 transition-all duration-200 hover:shadow-md">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-3 flex-shrink-0">
<div className="p-2 bg-purple-50 dark:bg-purple-900/30 rounded-lg">
<Building className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white">
{t("organization.selectUnit")}
</label>
</div>
</div>
<div className="flex-1 min-w-0">
<SingleSelect
options={unitsResponse?.data.items.map((u: any) => ({
value: u.id,
label: lang === "en" ? u.name.en : u.name.am,
}))}
onValueChange={handleUnitChange}
value={selectedUnitId ?? ""}
placeholder={t("delegation.selectDelegatedPosition")}
/>
{/* <Select
value={selectedUnitId}
onValueChange={handleUnitChange}
disabled={unitsLoading}
>
<SelectTrigger
className={cn(
"w-full border-gray-300 dark:border-gray-600 rounded-xl shadow-sm transition-all duration-200 dark:bg-gray-700 dark:text-white",
"focus:ring-2 focus:ring-purple-500 focus:border-purple-500",
"hover:border-gray-400 dark:hover:border-gray-500",
unitsLoading && "opacity-50 cursor-not-allowed",
)}
>
<SelectValue
placeholder={
unitsLoading
? t("common.loading")
: t("organization.selectUnit")
}
/>
</SelectTrigger>
<SelectContent className="rounded-xl border border-gray-200 dark:border-gray-600 shadow-lg dark:bg-gray-800">
{unitsResponse?.data.items.map((unit: UnitDto) => (
<SelectItem
key={unit.id}
value={unit.id}
className="rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-gray-200"
>
<div className="flex items-center gap-2">
<span className="flex-1 truncate">
{lang === "en" ? unit.name.en : unit.name.am}
</span>
{unit.id === selectedUnitId && (
<div className="w-2 h-2 bg-purple-600 dark:bg-purple-400 rounded-full" />
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select> */}
</div>
</div>
</div>
)}
{/* Main Content Grid */}
<div
className={cn(
"flex w-full min-h-[calc(100vh-12rem)] bg-transparent rounded-2xl",
isTemplateSampleActive ? "overflow-visible" : "overflow-hidden",
)}
>
{/* Mobile Overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 dark:bg-black/70 z-40 md:hidden backdrop-blur-sm transition-opacity duration-300"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<div
className={cn(
// Remove `fixed` and use `sticky` instead for proper scroll behavior
"md:sticky top-0 flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200/60 dark:border-gray-700 transition-all duration-300 ease-in-out",
"backdrop-blur-sm bg-white/95 md:bg-white dark:bg-gray-800/95",
isMobileMenuOpen
? "absolute left-0 top-0 z-40 w-72 lg:w-80"
: "hidden md:flex",
isSidebarCollapsed ? "md:w-16 lg:w-20" : "md:w-72 lg:w-80",
"h-[calc(100vh-2rem)] md:h-screen rounded-2xl md:rounded-none shadow-xl md:shadow-none",
)}
>
{/* Sidebar Header */}
<div
className={cn(
"flex items-center p-4 border-b border-gray-200/60 dark:border-gray-700 transition-all duration-300",
isSidebarCollapsed ? "justify-center" : "justify-between",
)}
>
{!isSidebarCollapsed && (
<div className="flex items-center gap-3 min-w-0">
<h2 className="text-lg font-bold text-gray-900 dark:text-white truncate">
{t("organization.contentManagement")}
</h2>
</div>
)}
<div className="flex items-center gap-1">
{/* Collapse Toggle - Desktop */}
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
className={cn(
"hidden md:flex p-2 rounded-xl hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-all duration-200",
"hover:shadow-sm border border-transparent hover:border-purple-200 dark:hover:border-purple-800",
)}
title={
isSidebarCollapsed
? t("expandSidebar")
: t("collapseSidebar")
}
>
{isSidebarCollapsed ? (
<Menu className="h-4 w-4 text-gray-600 dark:text-gray-300" />
) : (
<Menu className="h-4 w-4 text-gray-600 dark:text-gray-300" />
)}
</button>
{/* Close Button - Mobile */}
<button
onClick={() => setIsMobileMenuOpen(false)}
className="md:hidden p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X className="h-4 w-4 text-gray-600 dark:text-gray-300" />
</button>
</div>
</div>
{/* Navigation Menu */}
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{menuItems.map((item, index) => (
<button
key={index}
onClick={() => selectMenuItem(index, item.label)}
className={cn(
"w-full flex items-center gap-3 p-3 rounded-xl text-sm font-medium cursor-pointer transition-all duration-200",
"border border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-sm",
isSidebarCollapsed ? "justify-center" : "",
activeMenuIndex === index
? "bg-gradient-to-r from-purple-50 to-purple-50 dark:from-purple-900/30 dark:to-purple-900/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-700 shadow-sm"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white",
)}
>
<span
className={cn(
"transition-transform duration-200",
activeMenuIndex === index && "scale-110",
)}
>
{item.icon}
</span>
{!isSidebarCollapsed && (
<span className="flex-1 text-left truncate font-semibold">
{t(item.label)}
</span>
)}
</button>
))}
</nav>
</div>
{/* Main Content Area */}
<div
className={cn(
"flex-1 flex flex-col min-w-0 transition-all duration-300",
isSidebarCollapsed ? "md:ml-0" : "md:ml-0",
)}
>
{/* Mobile Header */}
<div className="md:hidden flex items-center justify-between p-4 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border-b border-gray-200/60 dark:border-gray-700 rounded-t-2xl">
<button
onClick={() => setIsMobileMenuOpen(true)}
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm border border-gray-200 dark:border-gray-600"
>
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</button>
<div className="flex-1 text-center">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{t(activeMenuItem?.label || "contentmanagement")}
</h1>
</div>
<div className="w-9"></div>
</div>
{/* Page Content */}
<div
className={cn(
"flex-1 p-4 md:p-6",
isTemplateSampleActive ? "overflow-visible" : "overflow-auto",
)}
>
{/* Breadcrumb Navigation */}
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-6 flex-wrap">
<span className="text-gray-400 dark:text-gray-500">
{t("content")}
</span>
{activeMenuItem && (
<>
<ChevronRight className="h-4 w-4 text-gray-400 dark:text-gray-500" />
<span className="text-gray-900 dark:text-white font-semibold bg-gray-100 dark:bg-gray-700 px-3 py-1 rounded-full text-sm">
{t(activeMenuItem.label)}
</span>
</>
)}
</div>
{/* Render the active element with loading state */}
<div className={cn("w-full transition-opacity duration-300")}>
<div
className={cn(
"rounded-2xl shadow-sm border border-gray-200/60 dark:border-gray-700",
isTemplateSampleActive
? "overflow-visible"
: "overflow-hidden",
)}
>
{activeMenuItem?.element}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContentManagement;

View File

@@ -0,0 +1,468 @@
import { useEffect, useState, useRef, useCallback } from "react";
import {
HeaderFooterResponseDto,
headerFooterService,
CreateHeaderFooterPayload,
HeaderFooterChangeStatusPayload,
} from "@/user-management/services/api/headerFooterService";
import { Card } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import i18n from "@/i18n";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { presignedAxios } from "@/shared/services/presignedAxios";
import useSettings from "@/record-management/components/hooks/useSettings";
import { OrganizationsPositions } from "@/record-management/services/api/departmentService";
import { MultiSelect } from "@/shared/common/ui/multi-select";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
import { KeyValue } from "@/record-management/types/recordSelectTypes";
import { FormSelectField } from "@/shared/common/form/fields/FormFields";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
}
const HeaderAndFooter = ({ unitId }: Props) => {
const [headers, setHeaders] = useState<HeaderFooterResponseDto[]>([]);
const [footers, setFooters] = useState<HeaderFooterResponseDto[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newFile, setNewFile] = useState<File | null>(null);
const [newType, setNewType] = useState<"header" | "footer">("header");
const [newPreview, setNewPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const queryClient = useQueryClient();
const lang = i18n.language;
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
const { data: unitConfigData } = useUnitConfiguration(unitId ?? "", {
enabled: !!unitId,
});
const unitConfig = unitConfigData?.data?.items?.[0];
const canCreateDirectRecord = unitConfig?.canCreateDirectRecord ?? false;
const localizedName = useLocalizedName();
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(
null,
);
const isDirectRecordType = (item: {
name?: { en?: string; am?: string };
key?: string;
}) => {
const key = String(item.key || "").toLowerCase();
const rawName = [item.name?.en, item.name?.am]
.filter(Boolean)
.join(" ")
.toLowerCase();
return key.includes("direct") || rawName.includes("direct");
};
const { recordTypes } = useSettings();
const { allDepratments } = useSettings();
const localizedFormName = useCallback(
(name?: { am: string; en: string }) => {
if (!name) return "";
return lang === "am" ? name.am || name.en : name.en || name.am;
},
[lang],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [headerListRes, footerListRes] = await Promise.all([
headerFooterService.getHeadersByUnitId(unitId),
headerFooterService.getFootersByUnitId(unitId),
]);
const activeUploadedHeaders = headerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const activeUploadedFooters = footerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const withPresigned = async (
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) =>
Promise.all(
items.map(async (item) => {
try {
const detail =
type === "header"
? await headerFooterService.getHeaderById(item.id)
: await headerFooterService.getFooterById(item.id);
return { ...item, presigned: detail.data.presigned };
} catch {
return item;
}
}),
);
const [activeHeaders, activeFooters] = await Promise.all([
withPresigned(activeUploadedHeaders, "header"),
withPresigned(activeUploadedFooters, "footer"),
]);
setHeaders(activeHeaders);
setFooters(activeFooters);
} catch {
toast.error(t("contentManagement.failedToLoadHeaderFooter"));
} finally {
setLoading(false);
}
}, [unitId]);
const handleStatusChange = async (
id: string,
type: "header" | "footer",
isCurrent: boolean,
) => {
try {
const payload: HeaderFooterChangeStatusPayload = { isCurrent };
if (type === "header") {
await headerFooterService.changeHeaderStatus(id, payload);
} else {
await headerFooterService.changeFooterStatus(id, payload);
}
// Invalidate the query cache so record forms immediately reflect the changes
queryClient.invalidateQueries({
queryKey: ["headers-footers", unitId],
});
toast.success(t("contentManagement.statusChanged"));
fetchData();
} catch {
toast.error(t("contentManagement.statusChangeFailed"));
}
};
const handleUpload = async () => {
if (!newName || !newFile) {
setError(t("contentManagement.provideMsg"));
return;
}
setUploading(true);
setError(null);
try {
const created = await (newType === "header"
? headerFooterService.uploadAndCreateHeader(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
)
: headerFooterService.uploadAndCreateFooter(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
));
// Make the newly uploaded resource active immediately.
if (created?.id) {
if (newType === "header") {
await headerFooterService.changeHeaderStatus(created.id, {
isCurrent: true,
});
} else {
await headerFooterService.changeFooterStatus(created.id, {
isCurrent: true,
});
}
}
toast.success(`${newType} ${t("contentManagement.uploadSuccess")}`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setNewFile(null);
setNewName("");
setNewPreview(null);
setError(null);
setSelectedPositions([]);
setSelectedRecordType(null);
fetchData();
} catch (err) {
console.error(err);
setError(t("contentManagement.uploadFailed"));
toast.error(t("contentManagement.uploadFailed"));
} finally {
setUploading(false);
}
};
useEffect(() => {
fetchData();
}, [fetchData]);
const recordTypeOptions: KeyValue[] =
recordTypes
?.filter((item: { name?: { en?: string; am?: string }; key?: string }) =>
canCreateDirectRecord ? true : !isDirectRecordType(item),
)
.map((item: { name: { en: string; am: string }; key: string }) => ({
label:
item.key == "direct"
? localizedName({ en: "Direct Letter", am: "ቀጥታ ደብዳቤ" })
: localizedName(item.name),
value: item.key,
})) || [];
const renderList = (
title: string,
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) => (
<Card className="p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-xl dark:text-white">{title}</h2>
<span className="text-sm text-muted-foreground dark:text-gray-400">
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</div>
{loading ? (
<Skeleton className="h-40" />
) : items.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-2xl text-gray-400 dark:text-gray-500">
{type === "header" ? "📄" : "📋"}
</span>
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 mb-2">
No {title.toLowerCase()} found
</p>
<p className="text-xs text-muted-foreground dark:text-gray-500">
Upload your first {type} using the form below
</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className={`border rounded-lg p-4 ${
item.isCurrent
? "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm"
: "bg-white dark:bg-gray-700"
}`}>
{/* Header with name and status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate dark:text-white">
{lang === "en" ? item.name.en : item.name.am}
</h3>
{item.isCurrent && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0">
{t("contentManagement.currentlyActive")}
</span>
)}
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 truncate">
{item.fileInfo.fileName}
</p>
</div>
</div>
{/* Preview and Actions */}
<div className="flex items-center justify-between gap-4">
{/* Preview Image */}
{item.presigned && (
<div className="flex-shrink-0">
<div className="w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600">
<img
src={item.presigned}
alt={`${type} Preview`}
className="w-full h-full object-contain"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => handleStatusChange(item.id, type, false)}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap">
{t("contentManagement.delete")}
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
);
return (
<div className="space-y-8">
<div className="grid lg:grid-cols-2 gap-8">
{renderList("Headers", headers, "header")}
{renderList("Footers", footers, "footer")}
</div>
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="font-semibold text-lg flex items-center gap-2 dark:text-white">
<Plus className="h-5 w-5" /> {t("contentManagement.addHeaderFooter")}
</h2>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.name")}
</Label>
<Input
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setError(null);
}}
placeholder={t("contentManagement.name")}
disabled={uploading}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.type")}
</Label>
<select
value={newType}
onChange={(e) =>
setNewType(e.target.value as "header" | "footer")
}
className="w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
disabled={uploading}>
<option value="header">{t("contentManagement.header")}</option>
<option value="footer">{t("contentManagement.footer")}</option>
</select>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.uploadFile")}
</Label>
<Input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={(e) => {
const file = e.target.files?.[0] || null;
if (file) {
const maxSizeMB = 5; // for example, 2 MB
if (file.size > maxSizeMB * 1024 * 1024) {
setError(`File size must be less than ${maxSizeMB} MB`);
if (fileInputRef.current) fileInputRef.current.value = "";
setNewFile(null);
setNewPreview(null);
return;
}
setNewFile(file);
setNewPreview(URL.createObjectURL(file));
} else {
setNewFile(null);
setNewPreview(null);
}
setError(null);
}}
disabled={uploading}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.positions")}
</Label>
<MultiSelect
options={
(allDepratments as OrganizationsPositions[] | undefined)?.map(
(dept) => ({
label: localizedFormName(dept.name),
value: dept.id,
}),
) ?? []
}
value={selectedPositions}
onValueChange={setSelectedPositions}
placeholder={t("contentManagement.selectPositions")}
maxCount={5}
className="w-full max-w-full sm:max-w-md overflow-x-auto"
animation={0}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("addRecord.Record Type")}
</Label>
<Select
value={selectedRecordType ?? ""}
onValueChange={(val) => setSelectedRecordType(val || null)}>
<SelectTrigger>
<SelectValue placeholder={t("addRecord.Select Record Type")} />
</SelectTrigger>
<SelectContent>
{recordTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{newPreview && (
<div className="mt-2">
<Label className="dark:text-gray-200">
{t("contentManagement.preview")}
</Label>
<div className="border rounded p-2 inline-block mt-1 dark:border-gray-600">
<img
src={newPreview}
alt="Preview"
className="h-24 object-contain rounded"
/>
</div>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button
onClick={handleUpload}
disabled={uploading}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
{uploading
? t("PDF.uploading")
: `${t("signatureUpload.upload")} ${newType}`}
</Button>
</Card>
</div>
);
};
export default HeaderAndFooter;

View File

@@ -0,0 +1,377 @@
import { Controller, useForm } from "react-hook-form";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
LetterTemplate,
LetterTemplatePayload,
} from "@/user-management/services/api/letterTemplateService";
import { TemplateEditor } from "@/super-admin/components/templates/components/TemplateEditor";
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
import { useMemo, useState, useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Card, CardContent } from "@/shared/common/ui/card";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
template?: LetterTemplate | null;
isSubmitting: boolean;
onCancel: () => void;
onSubmitCreate: (data: LetterTemplatePayload) => void;
}
export const LetterTemplateForm = ({
unitId,
template,
isSubmitting,
onCancel,
onSubmitCreate,
}: Props) => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedHeader, setSelectedHeader] = useState<string>("");
const [selectedFooter, setSelectedFooter] = useState<string>("");
const {
register,
handleSubmit,
reset,
control,
setValue,
watch,
formState: { errors },
} = useForm<LetterTemplatePayload>({
defaultValues: {
name: {
en: template?.name?.en ?? "",
am: template?.name?.am ?? "",
},
key: template?.key ?? "",
subject: template?.subject ?? "",
body: template?.body ?? "",
sincerelyText: template?.sincerelyText ?? "",
headerId: template?.headerId ?? "",
footerId: template?.footerId ?? "",
unitId,
},
});
const { data: headerFooterData } = useHeaderFooterPresigned(unitId);
const headers = useMemo(
() => headerFooterData?.headers || [],
[headerFooterData?.headers],
);
const footers = useMemo(
() => headerFooterData?.footers || [],
[headerFooterData?.footers],
);
const selectedHeaderId = watch("headerId");
const selectedFooterId = watch("footerId");
// Update header preview when selection changes
useEffect(() => {
if (headers.length && selectedHeaderId) {
const header = headers.find((h) => h.id === selectedHeaderId);
if (header) {
setSelectedHeader(
`<div class="letter-header"><img src="${header.presigned}" alt="Header" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedHeader("");
}
}, [headers, selectedHeaderId]);
// Update footer preview when selection changes
useEffect(() => {
if (footers.length && selectedFooterId) {
const footer = footers.find((f) => f.id === selectedFooterId);
if (footer) {
setSelectedFooter(
`<div class="letter-footer"><img src="${footer.presigned}" alt="Footer" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedFooter("");
}
}, [footers, selectedFooterId]);
const onSubmit = (values: LetterTemplatePayload) => {
onSubmitCreate(values);
if (!template) {
reset();
}
};
// Header options for select
const headerOptions = headers.map((header) => ({
label: localizedName(header.name),
value: header.id,
}));
// Footer options for select
const footerOptions = footers.map((footer) => ({
label: localizedName(footer.name),
value: footer.id,
}));
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm space-y-8"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.key", "Template Key")}
</label>
<Input
placeholder={t("template.keyPlaceholder", "Template Key")}
{...register("key", {
required: t("template.keyRequired", "Template key is required"),
})}
/>
{errors.key && (
<p className="text-red-500 text-sm mt-1">{errors.key.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.subject", "Subject")}
</label>
<Input
placeholder={t("template.subjectPlaceholder", "Subject")}
{...register("subject", {
required: t("template.subjectRequired", "Subject is required"),
})}
/>
{errors.subject && (
<p className="text-red-500 text-sm mt-1">
{errors.subject.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.englishName", "English Name")}
</label>
<Input
placeholder={t("template.englishNamePlaceholder", "English Name")}
{...register("name.en", {
required: t(
"template.englishNameRequired",
"English name is required",
),
})}
/>
{errors.name?.en && (
<p className="text-red-500 text-sm mt-1">
{errors.name.en.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.amharicName", "Amharic Name")}
</label>
<Input
placeholder={t("template.amharicNamePlaceholder", "Amharic Name")}
{...register("name.am", {
required: t(
"template.amharicNameRequired",
"Amharic name is required",
),
})}
/>
{errors.name?.am && (
<p className="text-red-500 text-sm mt-1">
{errors.name.am.message}
</p>
)}
</div>
{/* Header Selection */}
{headerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.header", "Header")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedHeaderId || ""}
onChange={(e) => setValue("headerId", e.target.value)}
>
<option value="">{t("template.noHeader", "No Header")}</option>
{headerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{/* Footer Selection */}
{footerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.footer", "Footer")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedFooterId || ""}
onChange={(e) => setValue("footerId", e.target.value)}
>
<option value="">{t("template.noFooter", "No Footer")}</option>
{footerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
</div>
{/* Header Preview */}
{selectedHeader && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.headerPreview", "Header Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedHeader }}
/>
</CardContent>
</Card>
)}
{/* Body Section */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.body", "Body")}
<span className="text-destructive ml-1">*</span>
</label>
<Controller
name="body"
control={control}
rules={{ required: t("template.bodyRequired", "Body is required") }}
render={({ field, fieldState }) => (
<>
<TemplateEditor
value={field.value}
onEditorChange={field.onChange}
placeholders={[
{
key: "delegatorName",
label: t("placeholder.delegatorName", "Delegator Name"),
},
{
key: "delegatorDepartment",
label: t(
"placeholder.delegatorDepartment",
"Delegator Department",
),
},
{
key: "delegateeName",
label: t("placeholder.delegateeName", "Delegatee Name"),
},
{
key: "delegateeDepartment",
label: t(
"placeholder.delegateeDepartment",
"Delegatee Department",
),
},
{
key: "startDate",
label: t("placeholder.startDate", "Start Date"),
},
{
key: "endDate",
label: t("placeholder.endDate", "End Date"),
},
{
key: "startDateTime",
label: t("placeholder.startDateTime", "Start Date & Time"),
},
{
key: "endDateTime",
label: t("placeholder.endDateTime", "End Date & Time"),
},
]}
/>
{fieldState.invalid && (
<p className="text-red-500 text-sm mt-1">
{fieldState.error?.message}
</p>
)}
</>
)}
/>
</div>
{/* Footer Preview */}
{selectedFooter && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.footerPreview", "Footer Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedFooter }}
/>
</CardContent>
</Card>
)}
{/* Sincerely Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.sincerelyText", "Sincerely Text")}
</label>
<Input
placeholder={t("template.sincerelyTextPlaceholder", "Sincerely Text")}
{...register("sincerelyText")}
/>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-4 justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? t("common.saving", "Saving...")
: template
? t("common.update", "Update")
: t("common.save", "Save")}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel", "Cancel")}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,154 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/common/ui/card";
import { FileText, Plus } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useState } from "react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
import { LetterTemplateForm } from "./LetterTemplateForm";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import i18n from "@/i18n";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
unitId: string;
}
const LetterTemplatesCard = ({ unitId }: Props) => {
const { handleError } = useErrorHandler(t);
const {
letterTemplatesResponse,
isLoading,
isError,
refetch,
createLetterTemplate,
updateLetterTemplate,
isCreating,
} = useLetterTemplates(unitId);
const lang = i18n.language;
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null
);
const [showCreateForm, setShowCreateForm] = useState(false);
const selectedTemplate =
letterTemplatesResponse?.items?.find((t) => t.id === selectedTemplateId) ??
null;
return (
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("contentManagement.createMsg")}
</CardDescription>
</div>
<Button onClick={() => setShowCreateForm(true)} size="sm">
<Plus className="h-4 w-4 mr-1" />
{t("contentManagement.addTemplate")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{isLoading ? (
<>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-20 w-full" />
</>
) : isError ? (
<p className="text-sm text-red-600">
{t("contentManagement.failedToLoadTemplates")}
</p>
) : (
<>
{letterTemplatesResponse && letterTemplatesResponse?.count > 0 && (
<Select
onValueChange={(value) => {
setSelectedTemplateId(value);
setShowCreateForm(false);
}}
value={selectedTemplateId ?? undefined}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
</SelectTrigger>
<SelectContent>
{letterTemplatesResponse.items.map((template) => (
<SelectItem key={template.id} value={template.id}>
{lang === "en" ? template.name.en : template.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(selectedTemplate || showCreateForm) && (
<LetterTemplateForm
unitId={unitId}
isSubmitting={isCreating}
template={selectedTemplate}
onCancel={() => {
setShowCreateForm(false);
setSelectedTemplateId(null);
}}
onSubmitCreate={(values) => {
if (selectedTemplate) {
updateLetterTemplate(
{ id: selectedTemplate.id, data: values },
{
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setShowCreateForm(false);
},
onError: (error) => {
handleError(error);
},
}
);
} else {
createLetterTemplate(values, {
onSuccess: (newTemplate) => {
toast.success(
`${newTemplate.name.en} ${t("contentManagement.templateSuccessMsg")}`
);
refetch();
setShowCreateForm(false);
setSelectedTemplateId(newTemplate.id);
},
onError: (error) => {
handleError(error);
},
});
}
}}
/>
)}
</>
)}
</CardContent>
</Card>
);
};
export default LetterTemplatesCard;

View File

@@ -0,0 +1,701 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import {
FileText,
Plus,
Edit,
Trash2,
Eye,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
import { LetterTemplateForm } from "./LetterTemplateForm";
import { LetterTemplate } from "@/user-management/services/api/letterTemplateService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { toast } from "sonner";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useTemplate } from "@/super-admin/components/templates/service/useTemplate";
import { headerFooterService } from "@/user-management/services/api/headerFooterService";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
unitId: string;
}
const LetterTemplatesTable = ({ unitId }: Props) => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// Pagination is server-side: skip/take are passed to the BE so we only
// pull one page at a time instead of every template at once.
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const {
letterTemplatesResponse,
count: localCount,
isLoading: isLocalLoading,
isFetching: isLocalFetching,
isError: isLocalError,
refetch,
createLetterTemplate,
updateLetterTemplate,
deleteLetterTemplate,
isCreating,
isUpdating,
isDeleting,
} = useLetterTemplates(unitId, {
skip: pageIndex * pageSize,
take: pageSize,
});
const totalPages = Math.max(1, Math.ceil(localCount / pageSize));
const {
templates: globalTemplatesResponse,
isLoading: isGlobalLoading,
adoptTemplate,
isAdoptingTemplate,
} = useTemplate();
const { data: headersResponse } = useQuery({
queryKey: ["headers", unitId],
queryFn: () => headerFooterService.getHeadersByUnitId(unitId),
});
const { data: footersResponse } = useQuery({
queryKey: ["footers", unitId],
queryFn: () => headerFooterService.getFootersByUnitId(unitId),
});
const localizedName = useLocalizedName();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<any | null>(null);
const [selectedHeaderId, setSelectedHeaderId] = useState<string>("");
const [selectedFooterId, setSelectedFooterId] = useState<string>("");
const deleteTemplateName = selectedTemplate
? localizedName(selectedTemplate.name)
: "";
const isLoading = isLocalLoading || isGlobalLoading;
const isError = isLocalError;
const globalTemplates = Array.isArray(globalTemplatesResponse)
? globalTemplatesResponse
: globalTemplatesResponse?.items || [];
const localTemplates = letterTemplatesResponse?.items || [];
const combinedTemplates = [
...globalTemplates.map((t: any) => ({ ...t, isGlobal: true })),
...localTemplates.map((t: any) => ({ ...t, isGlobal: false })),
];
const preventDialogCloseFromTinyMce = (event: Event) => {
const target = event.target as HTMLElement | null;
if (
target?.closest(
".tox-tinymce-aux, .moxman-window, .tam-assetmanager-root",
)
) {
event.preventDefault();
}
};
const handleCreate = (values: any) => {
createLetterTemplate(values, {
onSuccess: (newTemplate) => {
toast.success(
`${localizedName(newTemplate.name)} ${t(
"contentManagement.templateSuccessMsg",
)}`,
);
refetch();
setIsCreateDialogOpen(false);
},
onError: (error) => {
handleError(error);
},
});
};
const handleUpdate = (values: any) => {
if (!selectedTemplate) return;
updateLetterTemplate(
{ id: selectedTemplate.id, data: values },
{
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setIsEditDialogOpen(false);
setSelectedTemplate(null);
},
onError: (error) => {
handleError(error);
},
},
);
};
const handleDelete = () => {
if (!selectedTemplate) return;
deleteLetterTemplate(
{ id: selectedTemplate.id },
{
onSuccess: () => {
toast.success(t("contentManagement.deleteTemplate"));
refetch();
setIsDeleteDialogOpen(false);
setSelectedTemplate(null);
},
onError: (error) => {
handleError(error);
},
},
);
};
const handleAdopt = () => {
if (!selectedTemplate || !selectedHeaderId || !selectedFooterId) {
toast.error(
t(
"contentManagement.pleaseSelectHeaderAndFooter",
"Please select header and footer",
),
);
return;
}
adoptTemplate(
{
templateId: selectedTemplate.id,
headerId: selectedHeaderId,
footerId: selectedFooterId,
},
{
onSuccess: () => {
toast.success(
t(
"contentManagement.adoptSuccess",
"Successfully adopted global template",
),
);
refetch();
setIsAdoptDialogOpen(false);
setSelectedTemplate(null);
setSelectedHeaderId("");
setSelectedFooterId("");
},
onError: (error) => {
handleError(error);
},
},
);
};
const openEditDialog = (template: any) => {
setSelectedTemplate(template);
setIsEditDialogOpen(true);
};
const openViewDialog = (template: any) => {
setSelectedTemplate(template);
setIsViewDialogOpen(true);
};
const openDeleteDialog = (template: any) => {
setSelectedTemplate(template);
setIsDeleteDialogOpen(true);
};
const openAdoptDialog = (template: any) => {
setSelectedTemplate(template);
setIsAdoptDialogOpen(true);
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array(3)
.fill(0)
.map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-600 dark:text-red-400">
{t("contentManagement.failedToLoadTemplates")}
</p>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{t("contentManagement.createMsg")}
</p>
</div>
<Button
onClick={() => setIsCreateDialogOpen(true)}
size="sm"
className="shrink-0"
>
<Plus className="h-4 w-4 mr-1" />
{t("contentManagement.addTemplate")}
</Button>
</div>
</CardHeader>
<CardContent>
{combinedTemplates.length === 0 ? (
<div className="text-center py-8">
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-500" />
<p className="text-gray-500 dark:text-gray-400">
{t("contentManagement.noTemplates")}
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[150px]">
{t("common.name")}
</TableHead>
<TableHead className="min-w-[200px] hidden md:table-cell">
{t("contentManagement.sincerelyText")}
</TableHead>
<TableHead className="min-w-[120px] hidden sm:table-cell">
{t("common.createdDate")}
</TableHead>
<TableHead className="text-right min-w-[120px]">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{combinedTemplates.map((template) => (
<TableRow
key={template.id}
className={
template.isGlobal
? "bg-blue-50/50 hover:bg-blue-50 dark:bg-blue-950/30 dark:hover:bg-blue-900/40"
: ""
}
>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="flex items-center gap-2">
{localizedName(template.name)}
{template.isGlobal && (
<span className="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/50 dark:text-blue-200">
Global
</span>
)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 md:hidden">
{template.sincerelyText &&
template.sincerelyText.length > 30
? `${template.sincerelyText.substring(0, 30)}...`
: template.sincerelyText ||
t("common.notAvailable")}
</span>
</div>
</TableCell>
<TableCell className="max-w-xs truncate hidden md:table-cell">
{template.sincerelyText || t("common.notAvailable")}
</TableCell>
<TableCell className="hidden sm:table-cell">
{template.createdAt
? new Date(template.createdAt).toLocaleDateString()
: t("common.notAvailable")}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openViewDialog(template)}
title={t("contentManagement.viewTemplate")}
>
<Eye className="h-4 w-4" />
</Button>
{template.isGlobal ? (
<Button
variant="outline"
size="sm"
onClick={() => openAdoptDialog(template)}
title={t(
"contentManagement.adoptTemplate",
"Adopt Template",
)}
className="h-8 border-blue-200 px-3 text-blue-700 hover:bg-blue-100 hover:text-blue-800 dark:border-blue-700/60 dark:text-blue-300 dark:hover:bg-blue-900/40 dark:hover:text-blue-200"
>
{t("common.adopt", "Adopt")}
</Button>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(template)}
title={t("contentManagement.editTemplate")}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(template)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title={t("contentManagement.deleteTemplate")}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination controls — apply to local templates only.
Globals are typically a small fixed list and are shown
in the same table on every page. */}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{t("common.page", "Page")} {pageIndex + 1} / {totalPages}
{localCount > 0 && (
<>
{" "}
{localCount}{" "}
{t("contentManagement.letterTemplate", "templates")}
</>
)}
</div>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setPageIndex(0);
}}
>
<SelectTrigger className="h-8 w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={pageIndex === 0 || isLocalFetching}
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
{t("common.previous", "Previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={pageIndex + 1 >= totalPages || isLocalFetching}
onClick={() => setPageIndex((p) => p + 1)}
>
{t("common.next", "Next")}
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent
className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900"
onInteractOutside={preventDialogCloseFromTinyMce}
>
<DialogHeader>
<DialogTitle>{t("contentManagement.createTemplate")}</DialogTitle>
</DialogHeader>
<LetterTemplateForm
unitId={unitId}
isSubmitting={isCreating}
onCancel={() => setIsCreateDialogOpen(false)}
onSubmitCreate={handleCreate}
/>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent
className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900"
onInteractOutside={preventDialogCloseFromTinyMce}
>
<DialogHeader>
<DialogTitle>{t("contentManagement.editTemplate")}</DialogTitle>
</DialogHeader>
<LetterTemplateForm
unitId={unitId}
template={selectedTemplate}
isSubmitting={isUpdating}
onCancel={() => {
setIsEditDialogOpen(false);
setSelectedTemplate(null);
}}
onSubmitCreate={handleUpdate}
/>
</DialogContent>
</Dialog>
{/* View Dialog */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900">
<DialogHeader>
<DialogTitle>{t("contentManagement.viewTemplate")}</DialogTitle>
</DialogHeader>
{selectedTemplate && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("common.name")}
</label>
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{localizedName(selectedTemplate.name)}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("contentManagement.sincerelyText")}
</label>
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
{selectedTemplate.sincerelyText || t("common.notAvailable")}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("contentManagement.body")}
</label>
<div
className="mt-1 max-h-60 overflow-y-auto rounded-md border bg-gray-50 p-3 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: selectedTemplate.body }}
/>
</div>
<div className="flex justify-end">
<Button onClick={() => setIsViewDialogOpen(false)}>
{t("common.close")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Adopt Dialog */}
<Dialog open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent className="max-w-md dark:border-gray-700 dark:bg-gray-900">
<DialogHeader>
<DialogTitle>
{t("contentManagement.adoptTemplate", "Adopt Template")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("contentManagement.selectHeader", "Select Header")}
</label>
<Select
value={selectedHeaderId}
onValueChange={setSelectedHeaderId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"contentManagement.selectHeader",
"Select Header",
)}
/>
</SelectTrigger>
<SelectContent>
{headersResponse?.data?.items?.map((header: any) => (
<SelectItem key={header.id} value={header.id}>
{localizedName(header.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("contentManagement.selectFooter", "Select Footer")}
</label>
<Select
value={selectedFooterId}
onValueChange={setSelectedFooterId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"contentManagement.selectFooter",
"Select Footer",
)}
/>
</SelectTrigger>
<SelectContent>
{footersResponse?.data?.items?.map((footer: any) => (
<SelectItem key={footer.id} value={footer.id}>
{localizedName(footer.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsAdoptDialogOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdopt}
disabled={
isAdoptingTemplate || !selectedHeaderId || !selectedFooterId
}
>
{isAdoptingTemplate
? t("common.adopting", "Adopting...")
: t("common.adopt", "Adopt")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("contentManagement.deleteTemplate")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.deleteTemplateConfirm", {
name: deleteTemplateName,
})
// Fallback if resources still use the legacy `{name}` placeholder.
.replace("{{name}}", deleteTemplateName)
.replace("{name}", deleteTemplateName)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? t("common.deleting") : t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default LetterTemplatesTable;

View File

@@ -0,0 +1,653 @@
// PrefixManagement.tsx
import React, { useState, useEffect, useMemo, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle } from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { usePositions } from "@/user-management/hooks/usePosition";
import { t } from "i18next";
import { PrefixSuffixTable } from "./PrefixSuffixTable";
import { PrefixModal } from "./PrefixModal";
import {
useCCPrefixesList,
useCCSuffixesList,
useDeletePrefix,
useDeleteSuffix,
usePositionPrefixesList,
usePrefixesList,
useSuffixesList,
} from "@/user-management/hooks/usePrefixSuffixes";
import {
useGetReferenceNumbers,
} from "@/shared/hooks/useReferenceNumberPrefixes";
import { useDeleteReferenceNumber } from "@/shared/hooks/useReferenceNumberPrefixes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { toast } from "sonner";
import { TagBasedReferenceNumbers } from "./TagBasedReferenceNumbers";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
export type PrefixTabType =
| "internal"
| "external"
| "internal_memo"
| "branch"
| "tag-based-reference"
| "referenceNumber"
| "positionPrefix";
const tabs: { id: PrefixTabType; label: string }[] = [
{ id: "internal", label: "nav.internal" },
{ id: "external", label: "nav.external" },
{ id: "internal_memo", label: "nav.internal_memo" },
{ id: "branch", label: "nav.branch" },
{ id: "tag-based-reference", label: "nav.tagBasedReference" },
{ id: "referenceNumber", label: "userRecord.Reference" },
{ id: "positionPrefix", label: "contentManagement.positionPrefixes" },
];
interface PrefixManagementProps {
unitId: string;
initialTab?: PrefixTabType;
}
const getDefaultCardForTab = (tab: PrefixTabType): string => {
if (tab === "positionPrefix") return "internalPrefix";
if (tab === "referenceNumber") return "reference";
return "prefix";
};
export const PrefixAndSuffix = ({
unitId,
initialTab = "internal",
}: PrefixManagementProps) => {
const { handleError } = useErrorHandler(t);
const [activeTab, setActiveTab] = useState<PrefixTabType>(initialTab);
const [activeCard, setActiveCard] = useState<string>(
getDefaultCardForTab(initialTab),
);
const [modalOpen, setModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState<{
cardType: string;
recordType: PrefixTabType;
editingItem?: any;
} | null>(null);
const queryClient = useQueryClient();
const { mutateAsync: deletePrefix } = useDeletePrefix();
const { mutateAsync: deleteSuffix } = useDeleteSuffix();
const { mutateAsync: deleteReferenceNumber, isPending: isDeletingReference } =
useDeleteReferenceNumber();
const [pendingReferenceDelete, setPendingReferenceDelete] = useState<{
id: string;
type: string;
} | null>(null);
// Fetch positions for the position prefix tab
const { usePositionListByUnitId } = usePositions();
const { data: positionsResponse } = usePositionListByUnitId(unitId, {
take: 1000,
skip: 0,
});
const positions = positionsResponse?.items || [];
// Update active tab when the sidebar submenu changes
useEffect(() => {
setActiveTab(initialTab);
setActiveCard(getDefaultCardForTab(initialTab));
}, [initialTab]);
useEffect(() => {
setActiveCard(getDefaultCardForTab(activeTab));
}, [activeTab]);
const handleAddClick = (cardType: string) => {
setModalConfig({ cardType, recordType: activeTab });
setModalOpen(true);
};
const handleEdit = (item: any) => {
setModalConfig({
cardType: item.type, // e.g., "prefix", "suffix", "reference"
recordType: activeTab,
editingItem: item,
});
setModalOpen(true);
};
const handleDelete = async (id: string, type: string, recordType: string) => {
console.log("Delete requested for:", { id, type, recordType });
const isReferenceType =
type === "reference" ||
type === "externalReference" ||
type === "internalMemoReference";
if (isReferenceType) {
setPendingReferenceDelete({ id, type });
return;
}
const isPrefixType =
type === "prefix" ||
type === "prefixCC" ||
type === "internalPrefix" ||
type === "externalPrefix" ||
type === "internalMemoPrefix";
const isForCC = type === "prefixCC" || type === "suffixCC";
const resolvedRecordTypeKey =
recordType ||
(type === "internalPrefix"
? "internal"
: type === "externalPrefix"
? "external"
: type === "internalMemoPrefix"
? "internal_memo"
: activeTab);
try {
if (isPrefixType) {
await deletePrefix({
id,
unitId,
recordTypeKey: resolvedRecordTypeKey,
isForCC,
});
if (activeTab === "positionPrefix") {
queryClient.invalidateQueries({
queryKey: ["prefixes-by-position", unitId, resolvedRecordTypeKey],
});
}
} else {
await deleteSuffix({
id,
unitId,
recordTypeKey: resolvedRecordTypeKey,
isForCC,
});
}
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
toast.success(t("contentManagement.deleted"));
} catch (error) {
void handleError(error);
}
};
const confirmReferenceDelete = async () => {
if (!pendingReferenceDelete) return;
if (!pendingReferenceDelete.id) {
toast.error(
t(
"prefixes.deleteMissingId",
"Unable to delete: missing configuration id.",
),
);
return;
}
const sequenceType =
pendingReferenceDelete.type === "externalReference"
? "external"
: pendingReferenceDelete.type === "internalMemoReference"
? "internal_memo"
: "internal";
try {
await deleteReferenceNumber({
unitId,
id: pendingReferenceDelete.id,
payload: { recordSequenceTypes: [sequenceType] },
});
setPendingReferenceDelete(null);
toast.success(t("contentManagement.deleted"));
} catch (error) {
void handleError(error);
}
};
const handleModalClose = () => {
setModalOpen(false);
setModalConfig(null);
};
const handleModalSuccess = () => {
const targetCardType = modalConfig?.cardType ?? activeCard;
const targetTab = modalConfig?.recordType ?? activeTab;
const targetIsCC =
targetCardType === "prefixCC" || targetCardType === "suffixCC";
const targetIsPrefix =
targetCardType === "prefix" ||
targetCardType === "prefixCC" ||
targetCardType === "internalPrefix" ||
targetCardType === "externalPrefix" ||
targetCardType === "internalMemoPrefix" ||
targetTab === "referenceNumber";
const targetRecordTypeKey =
targetTab === "referenceNumber"
? targetCardType
: targetTab === "positionPrefix"
? targetCardType === "internalPrefix"
? "internal"
: targetCardType === "internalMemoPrefix"
? "internal_memo"
: "external"
: targetTab;
if (targetIsPrefix) {
if (targetTab === "positionPrefix") {
queryClient.invalidateQueries({
queryKey: ["prefixes-by-position", unitId, targetRecordTypeKey],
});
}
queryClient.invalidateQueries({
queryKey: ["prefixes", unitId, targetRecordTypeKey, { cc: targetIsCC }],
});
} else {
queryClient.invalidateQueries({
queryKey: ["suffixes", unitId, targetRecordTypeKey, { cc: targetIsCC }],
});
}
// Keep old key invalidation for legacy consumers.
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
handleModalClose();
};
// Define cards for the active tab
const getCards = () => {
if (activeTab === "positionPrefix") {
return [
{ id: "internalPrefix", label: "contentManagement.internalPrefix" },
{ id: "externalPrefix", label: "contentManagement.externalPrefix" },
{
id: "internalMemoPrefix",
label: "contentManagement.internalMemoPrefix",
},
];
}
if (activeTab === "referenceNumber") {
return [
{ id: "reference", label: "contentManagement.reference" },
{
id: "externalReference",
label: "contentManagement.externalReference",
},
{
id: "internalMemoReference",
label: "contentManagement.internalMemoReference",
},
];
}
// internal, external, internal_memo
return [
{ id: "prefix", label: "contentManagement.prefix" },
{ id: "suffix", label: "contentManagement.suffix" },
{ id: "prefixCC", label: "contentManagement.prefixCC" },
{ id: "suffixCC", label: "contentManagement.suffixCC" },
];
};
const cards = getCards();
useEffect(() => {
if (!cards.some((card) => card.id === activeCard)) {
setActiveCard(cards[0]?.id || getDefaultCardForTab(activeTab));
}
}, [cards, activeCard, activeTab]);
const isCC = activeCard === "prefixCC" || activeCard === "suffixCC";
const isReferenceTab = activeTab === "referenceNumber";
const isPositionPrefixTab = activeTab === "positionPrefix";
const isTagBasedTab = activeTab === "tag-based-reference";
const isPrefix =
activeCard === "prefix" ||
activeCard === "prefixCC" ||
activeCard === "internalPrefix" ||
activeCard === "externalPrefix" ||
activeCard === "internalMemoPrefix" ||
isReferenceTab;
const positionRecordTypeKey =
activeCard === "internalPrefix"
? "internal"
: activeCard === "externalPrefix"
? "external"
: activeCard === "internalMemoPrefix"
? "internal_memo"
: "";
const recordTypeKey =
activeTab === "positionPrefix" ? positionRecordTypeKey : activeTab;
const {
data: referenceNumbersData,
isLoading: isLoadingReferenceNumbers,
error: referenceNumbersError,
} = useGetReferenceNumbers(isReferenceTab ? unitId : "");
const {
data: prefixesData,
isLoading: isLoadingPrefixes,
error: prefixesError,
} = usePrefixesList(
unitId,
isPrefix &&
!isCC &&
!isReferenceTab &&
!isPositionPrefixTab &&
!isTagBasedTab
? recordTypeKey
: "",
0,
1000,
);
const {
data: positionPrefixesData,
isLoading: isLoadingPositionPrefixes,
error: positionPrefixesError,
} = usePositionPrefixesList(
unitId,
isPositionPrefixTab ? positionRecordTypeKey : "",
0,
1000,
);
const {
data: ccPrefixesData,
isLoading: isLoadingCCPrefixes,
error: ccPrefixesError,
} = useCCPrefixesList(
unitId,
isPrefix && isCC && !isReferenceTab ? recordTypeKey : "",
0,
1000,
);
const {
data: suffixesData,
isLoading: isLoadingSuffixes,
error: suffixesError,
} = useSuffixesList(
unitId,
!isPrefix && !isCC && !isReferenceTab ? recordTypeKey : "",
0,
1000,
);
const {
data: ccSuffixesData,
isLoading: isLoadingCCSuffixes,
error: ccSuffixesError,
} = useCCSuffixesList(
unitId,
!isPrefix && isCC && !isReferenceTab ? recordTypeKey : "",
0,
1000,
);
const firstQueryError = useMemo(
() =>
[
referenceNumbersError,
positionPrefixesError,
prefixesError,
ccPrefixesError,
suffixesError,
ccSuffixesError,
].find(Boolean),
[
referenceNumbersError,
positionPrefixesError,
prefixesError,
ccPrefixesError,
suffixesError,
ccSuffixesError,
],
);
const lastHandledErrorRef = useRef<unknown>(null);
useEffect(() => {
if (!firstQueryError || firstQueryError === lastHandledErrorRef.current) {
return;
}
lastHandledErrorRef.current = firstQueryError;
void handleError(firstQueryError);
}, [firstQueryError, handleError]);
const referenceNumbers = referenceNumbersData?.data || [];
const referenceNumberPrefix = referenceNumbers?.find(
(rn: any) => rn.name === "referenceNumberPrefix",
);
const externalReferenceNumberPrefix = referenceNumbers?.find(
(rn: any) => rn.name === "externalReferenceNumberPrefix",
);
const internalMemoReferenceNumberPrefix = referenceNumbers?.find(
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
);
const getAmValue = (value: any): string => {
if (!value) return "";
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return parsed?.am ?? value;
} catch {
return value;
}
}
if (typeof value === "object") {
return value?.am ?? "";
}
return "";
};
const branchItems = [
{
id: unitId,
type: "reference",
recordTypeKey: "reference",
isForCC: false,
name: {
am: getAmValue(referenceNumberPrefix?.number),
en: getAmValue(referenceNumberPrefix?.number?.en),
},
count: referenceNumberPrefix?.count ?? 0,
sequenceId: referenceNumberPrefix?.sequenceId,
},
{
id: unitId,
type: "externalReference",
recordTypeKey: "externalReference",
isForCC: false,
name: {
am: getAmValue(externalReferenceNumberPrefix?.number?.am),
en: getAmValue(externalReferenceNumberPrefix?.number?.en),
},
count: externalReferenceNumberPrefix?.count ?? 0,
sequenceId: externalReferenceNumberPrefix?.sequenceId,
},
{
id: unitId,
type: "internalMemoReference",
recordTypeKey: "internalMemoReference",
isForCC: false,
name: {
am: getAmValue(internalMemoReferenceNumberPrefix?.number?.am),
en: getAmValue(internalMemoReferenceNumberPrefix?.number?.en),
},
count: internalMemoReferenceNumberPrefix?.count ?? 0,
sequenceId: internalMemoReferenceNumberPrefix?.sequenceId,
},
].filter((item) => item.type === activeCard);
const activeResponse = isPositionPrefixTab
? positionPrefixesData
: isPrefix
? isCC
? ccPrefixesData
: prefixesData
: isCC
? ccSuffixesData
: suffixesData;
const items = isReferenceTab
? branchItems
: activeResponse?.data?.items?.map((item: any) => ({
...item,
type:
activeTab === "positionPrefix"
? item.recordTypeKey === "internal"
? "internalPrefix"
: item.recordTypeKey === "internal_memo"
? "internalMemoPrefix"
: "externalPrefix"
: isPrefix
? isCC
? "prefixCC"
: "prefix"
: isCC
? "suffixCC"
: "suffix",
isForCC: isCC || item.isForCC,
})) || [];
const isLoading =
isLoadingReferenceNumbers ||
isLoadingPositionPrefixes ||
isLoadingPrefixes ||
isLoadingCCPrefixes ||
isLoadingSuffixes ||
isLoadingCCSuffixes;
return (
<div className="space-y-6 p-4">
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setActiveCard(getDefaultCardForTab(tab.id));
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-purple-600 text-purple-600 dark:text-purple-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
{t(tab.label)}
</button>
))}
</div>
{/* Cards */}
{activeTab === "tag-based-reference" ? (
<TagBasedReferenceNumbers unitId={unitId} className="w-full" />
) : (
<>
<div className="flex flex-wrap gap-4">
{cards.map((card) => (
<Card
key={card.id}
onClick={() => setActiveCard(card.id)}
className={`inline-flex w-fit min-w-[260px] max-w-full shadow-sm hover:shadow-md transition-shadow cursor-pointer dark:bg-gray-800 dark:border-gray-700 ${
activeCard === card.id
? "ring-2 ring-purple-600 dark:ring-purple-400"
: ""
}`}
>
<CardHeader className="pb-2 items-center">
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-200 text-center whitespace-nowrap">
{t(card.label)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="flex justify-end mb-4">
<Button onClick={() => handleAddClick(activeCard)}>
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.add")}
</Button>
</div>
{/* Table */}
<PrefixSuffixTable
items={items}
isLoading={isLoading}
onEdit={handleEdit}
onDelete={handleDelete}
showPositionColumn={activeTab === "positionPrefix"}
showCountColumn={
activeTab === "positionPrefix" || activeTab === "referenceNumber"
}
positions={positions}
/>
{/* Modal */}
{modalOpen && modalConfig && (
<PrefixModal
open={modalOpen}
onClose={handleModalClose}
onSuccess={handleModalSuccess}
unitId={unitId}
cardType={modalConfig.cardType}
recordType={modalConfig.recordType}
editingItem={modalConfig.editingItem}
positions={positions}
/>
)}
<AlertDialog
open={!!pendingReferenceDelete}
onOpenChange={(open) => {
if (!open && !isDeletingReference) {
setPendingReferenceDelete(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(
"prefixes.deleteConfirmTitle",
"Delete reference number prefix?",
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"prefixes.deleteConfirmDescription",
"Are you sure you want to delete this prefix? This action cannot be undone.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingReference}>
{t("common.cancel") || "Cancel"}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmReferenceDelete}
disabled={isDeletingReference}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeletingReference
? t("contentManagement.loading", "Loading...")
: t("common.delete", "Delete") || "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,409 @@
// PrefixModal.tsx
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreatePrefixPayload,
CreateSuffixPayload,
prefixSuffixService,
} from "@/user-management/services/api/prefixSuffixService";
import { t } from "i18next";
import { PrefixTabType } from "./PrefixAndSuffixCard";
import { toast } from "sonner";
import {
useAddReferenceNumber,
useGetReferenceNumbers,
useUpdateExternalReferencePrefix,
useUpdateInternalMemoReferencePrefix,
} from "@/shared/hooks/useReferenceNumberPrefixes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { SingleSelect } from "@/shared/common/ui/single-select";
interface PrefixModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
unitId: string;
cardType: string; // e.g., "prefix", "suffix", "prefixCC", "reference", "internalPrefix", etc.
recordType: PrefixTabType;
editingItem?: any;
positions?: any[];
}
export const PrefixModal = ({
open,
onClose,
onSuccess,
unitId,
cardType,
recordType,
editingItem,
positions = [],
}: PrefixModalProps) => {
const queryClient = useQueryClient();
const isEditing = !!editingItem;
// Form state
const [nameAm, setNameAm] = useState("");
const [nameEn, setNameEn] = useState("");
const [positionId, setPositionId] = useState("");
// Determine if this is a reference type
const isReference = [
"reference",
"externalReference",
"internalMemoReference",
].includes(cardType);
const isPositionPrefix = recordType === "positionPrefix";
const isCC = cardType.includes("CC");
const isReferenceCard = cardType === "reference";
const isExternalReferenceCard = cardType === "externalReference";
const isInternalMemoReferenceCard = cardType === "internalMemoReference";
const positionOptions = positions
.filter((pos) => Boolean(pos?.id))
.map((pos) => ({
value: pos.id,
label: pos.name?.en || pos.name?.am || pos.id,
}));
const positionRecordTypeKey =
cardType === "internalPrefix"
? "internal"
: cardType === "externalPrefix"
? "external"
: cardType === "internalMemoPrefix"
? "internal_memo"
: editingItem?.recordTypeKey;
const { data: referenceNumbersData } = useGetReferenceNumbers(
isReference ? unitId : "",
);
const addReferenceNumberMutation = useAddReferenceNumber();
const updateInternalMemoReferenceMutation =
useUpdateInternalMemoReferencePrefix();
const updateExternalReferenceMutation = useUpdateExternalReferencePrefix();
const { handleError } = useErrorHandler(t);
const referenceNumbers =
((referenceNumbersData?.data as any)?.items?.[0] as any) ||
referenceNumbersData?.data ||
{};
const isArray = Array.isArray(referenceNumbers);
const referenceNumberPrefix =
isArray &&
referenceNumbers?.find((rn: any) => rn.name === "referenceNumberPrefix");
const externalReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "externalReferenceNumberPrefix",
);
const internalMemoReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
);
// Populate form when editing
useEffect(() => {
if (editingItem) {
if (isReference) {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
} else {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
}
setPositionId(editingItem.positionId || "");
} else {
// Reset
setNameAm("");
setNameEn("");
setPositionId("");
}
}, [editingItem, isReference]);
useEffect(() => {
if (!isReference || editingItem) return;
if (isReferenceCard) {
setNameAm(referenceNumbers.referenceNumberPrefix || "");
setNameEn(referenceNumbers.referenceNumberPrefix || "");
return;
}
if (isExternalReferenceCard) {
setNameAm(referenceNumbers.externalReferenceNumberPrefix || "");
setNameEn(referenceNumbers.externalReferenceNumberPrefix || "");
return;
}
if (isInternalMemoReferenceCard) {
setNameAm(referenceNumbers.internalMemoReferenceNumberPrefix || "");
setNameEn(referenceNumbers.internalMemoReferenceNumberPrefix || "");
}
}, [
editingItem,
isReference,
isReferenceCard,
isExternalReferenceCard,
isInternalMemoReferenceCard,
referenceNumbers.referenceNumberPrefix,
referenceNumbers.externalReferenceNumberPrefix,
referenceNumbers.internalMemoReferenceNumberPrefix,
]);
// Mutation for saving
const { mutate: save, isPending } = useMutation({
mutationFn: async () => {
const baseRecordTypeKey = recordType;
const isPrefixOperation =
isReference ||
isPositionPrefix ||
cardType === "prefix" ||
cardType === "prefixCC" ||
cardType === "internalPrefix" ||
cardType === "externalPrefix" ||
cardType === "internalMemoPrefix";
let prefixPayload: CreatePrefixPayload | undefined;
let suffixPayload: CreateSuffixPayload | undefined;
if (isReference) {
if (isReferenceCard) {
await addReferenceNumberMutation.mutateAsync({
unitId,
payload: {
referenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isExternalReferenceCard) {
await updateExternalReferenceMutation.mutateAsync({
unitId,
payload: {
externalReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isInternalMemoReferenceCard) {
await updateInternalMemoReferenceMutation.mutateAsync({
unitId,
payload: {
internalMemoReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
} else if (isPositionPrefix) {
if (
positionRecordTypeKey !== "internal" &&
positionRecordTypeKey !== "external" &&
positionRecordTypeKey !== "internal_memo"
) {
throw new Error(
"Position prefix requires internal, external, or internal_memo recordTypeKey",
);
}
prefixPayload = {
unitId,
recordTypeKey: positionRecordTypeKey,
isForCC: false,
positionId,
name: { am: nameAm, en: nameEn },
};
} else {
const commonPayload = {
unitId,
recordTypeKey: baseRecordTypeKey,
isForCC: isCC,
name: { am: nameAm, en: nameEn },
};
if (cardType === "suffix" || cardType === "suffixCC") {
suffixPayload = commonPayload;
} else {
prefixPayload = commonPayload;
}
}
if (isEditing) {
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.editPrefix(editingItem.id, prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.editSuffix(editingItem.id, suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for update");
}
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.createPrefix(prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.createSuffix(suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for create");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
toast.success(t("msg.successfullyCompleted"));
onSuccess();
},
onError: (error) => {
handleError(error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
save();
};
const isValid = () => {
if (isReference) return nameAm.trim() !== "" && nameEn.trim() !== "";
if (isPositionPrefix)
return nameAm.trim() !== "" && nameEn.trim() !== "" && positionId;
return nameAm.trim() !== "" && nameEn.trim() !== "";
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{isEditing
? t("contentManagement.edit")
: t("contentManagement.add")}{" "}
{t(`contentManagement.${cardType}`)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isReference ? (
<div className="space-y-2">
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</>
)}
{isPositionPrefix && (
<div className="space-y-2">
<Label htmlFor="position" className="dark:text-gray-200">
{t("contentManagement.position")} *
</Label>
<SingleSelect
options={positionOptions}
value={positionId}
onValueChange={setPositionId}
placeholder={t("contentManagement.selectPosition")}
className={isPending ? "pointer-events-none opacity-60" : ""}
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isPending}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={!isValid() || isPending}
className="bg-purple-600 hover:bg-purple-700"
>
{isPending
? t("contentManagement.saving")
: (isReferenceCard &&
(referenceNumbers.referenceNumberPrefix ||
referenceNumbers.externalReferenceNumberPrefix ||
referenceNumbers.internalMemoReferenceNumberPrefix)) ||
isEditing
? t("common.update")
: t("common.save")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,246 @@
// PrefixSuffixTable.tsx
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Pencil, Trash2, X } from "lucide-react";
import { t } from "i18next";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { useQueryClient } from "@tanstack/react-query";
interface PrefixSuffixTableProps {
items: any[];
isLoading: boolean;
onEdit: (item: any) => void;
onDelete: (id: string, type: string, recordType: string) => void;
showPositionColumn?: boolean;
showCountColumn?: boolean;
positions?: any[];
}
export const PrefixSuffixTable = ({
items,
isLoading,
onEdit,
onDelete,
showPositionColumn = false,
showCountColumn = false,
positions = [],
}: PrefixSuffixTableProps) => {
const queryClient = useQueryClient();
const getPositionName = (positionId: string) => {
const pos = positions.find((p) => p.id === positionId);
return pos?.name?.en || pos?.name?.am || positionId;
};
const [countPopup, setCountPopup] = useState<{
itemId: string;
currentCount: number;
} | null>(null);
const [countValue, setCountValue] = useState("");
// 1. Helper to get count from either source
const getCount = (item: any): number | undefined => {
return item.recordSequences?.[0]?.count ?? item.count ?? undefined;
};
// 2. Helper to get sequence ID from either source
const getSequenceId = (item: any): string | undefined => {
return item.recordSequences?.[0]?.id ?? item.sequenceId ?? undefined;
};
// 3. Updated openCountPopup
const openCountPopup = (item: any) => {
const currentCount = getCount(item) ?? 0;
const sequenceId = getSequenceId(item);
if (!sequenceId) {
console.error("No sequence ID found for item:", item);
return;
}
setCountPopup({ itemId: sequenceId, currentCount });
setCountValue(String(currentCount));
};
const closeCountPopup = () => {
setCountPopup(null);
setCountValue("");
queryClient.invalidateQueries({
queryKey: ["prefixes-by-position"],
});
queryClient.invalidateQueries({
queryKey: ["unit-reference-numbers"],
});
};
const handleCountSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!countPopup) return;
const newCount = parseInt(countValue, 10);
if (isNaN(newCount) || newCount < 0) return;
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
closeCountPopup();
});
};
return (
<div className="overflow-x-auto border rounded-lg dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead className="bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
<tr>
<th className="px-4 py-3 dark:text-white">#</th>
<th className="px-4 py-3 dark:text-white">{t("header.amharic")}</th>
<th className="px-4 py-3 dark:text-white">{t("header.english")}</th>
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.type")}
</th>
{showPositionColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.position")}
</th>
)}
{showCountColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.count")}
</th>
)}
<th className="px-4 py-3 dark:text-white">
{t("userRecord.Actions")}
</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.loading")}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
) : (
items.map((item, idx) => {
return (
<tr
key={item.id}
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-4 py-3 dark:text-gray-300">{idx + 1}</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.am || "-"}
</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.en || "-"}
</td>
<td className="px-4 py-3 capitalize dark:text-gray-300">
{item.type}
{item.isForCC && (
<span className="ml-1 text-xs text-purple-600 dark:text-purple-400">
(CC)
</span>
)}
</td>
{showPositionColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{item.positionId ? getPositionName(item.positionId) : "-"}
</td>
)}
{showCountColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{(() => {
const count = getCount(item);
if (count === undefined) return "-";
return (
<button
onClick={() => openCountPopup(item)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer">
{count}
</button>
);
})()}
</td>
)}
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
onClick={() =>
onDelete(item.id, item.type, item.recordTypeKey)
}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
{/* Count Update Popup */}
{countPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold dark:text-white">
{t("contentManagement.updateCount")}
</h3>
<button
onClick={closeCountPopup}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleCountSubmit}>
<div className="space-y-2 mb-4">
<Label htmlFor="countValue" className="dark:text-gray-200">
{t("contentManagement.value")} *
</Label>
<Input
id="countValue"
type="number"
min="0"
value={countValue}
onChange={(e) => setCountValue(e.target.value)}
placeholder={t("contentManagement.enterValue")}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("contentManagement.currentCount")}:{" "}
{countPopup.currentCount}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={closeCountPopup}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("common.submit")}</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,223 @@
import { useState, useEffect, useCallback } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/shared/common/ui/card";
import { FileText, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Activity } from "./ContentManagement";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import { Button } from "@/shared/common/ui/button";
import { listAuditLogExtensions, AuditLogExtensionItem } from "@/shared/services/audit/audit.api";
interface Props {
activities?: Activity[];
pageSize?: number;
}
const RecentActivitiesCard = ({ activities = [], pageSize = 10 }: Props) => {
const { t } = useTranslation();
const [auditLogs, setAuditLogs] = useState<AuditLogExtensionItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [allLogs, setAllLogs] = useState<AuditLogExtensionItem[]>([]);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
// Fetch all 1000 records at once
const data = await listAuditLogExtensions(
"/audit-log-extensions/audit/unitAdmin",
{
skip: 0,
take: 1000,
orderBy: "createdAt:DESC",
}
);
const logs = data.items || [];
setAllLogs(logs);
setTotalCount(logs.length);
setCurrentPage(1); // Reset to first page
} catch (error: any) {
console.error("Error fetching audit logs:", error);
// Silently fail for now - don't show error toast on load
// as this is a supplementary feature
setAllLogs([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAuditLogs();
// Refresh every 30 seconds
const interval = setInterval(fetchAuditLogs, 30000);
return () => clearInterval(interval);
}, [fetchAuditLogs]);
// Paginate the allLogs whenever currentPage changes
useEffect(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setAuditLogs(allLogs.slice(startIndex, endIndex));
}, [currentPage, pageSize, allLogs]);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
return (
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="flex items-center dark:text-white">
<FileText className="h-5 w-5 mr-2" />
{t("dashboard.recentActivities", "Recent Activities")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("organization.recentContentActivities", "Recent content management activities")}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => fetchAuditLogs()}
disabled={loading}
className="ml-2"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Display audit logs if available */}
{auditLogs.length > 0 ? (
<div className="space-y-3">
{auditLogs.map((log, idx) => {
const userName = i18n.language === "am" ? log.user.name.am : log.user.name.en;
const entityLabel = log.entityName.replace(/_/g, " ");
const timestamp = new Date(log.createdAt).toLocaleString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<div
key={log.id || idx}
className="p-3 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:shadow-sm transition-shadow"
>
<p className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-semibold">{userName}</span>
{" "}
<span className="text-gray-600 dark:text-gray-300">
{log.queryMethod.toLowerCase() === "insert"
? "created"
: log.queryMethod.toLowerCase() === "update"
? "updated"
: log.queryMethod.toLowerCase() === "delete"
? "deleted"
: log.queryMethod.toLowerCase()}
</span>
{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{entityLabel}
</span>
{" "}
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
</p>
</div>
);
})}
</div>
) : activities.length > 0 ? (
<div className="space-y-4">
{activities.map(({ id, type, description, timestamp }) => (
<div key={id} className="flex justify-between items-center">
<span className="text-sm font-medium capitalize dark:text-white">{type}</span>
<span className="text-sm dark:text-gray-300">{description}</span>
<span className="text-xs text-muted-foreground dark:text-gray-400">
{formatDate(timestamp)}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground dark:text-gray-400 text-center py-8">
{loading ? (
<div className="flex items-center justify-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>{t("common.loading", "Loading...")}</span>
</div>
) : (
t("auditLog.noRecords", "No recent activities.")
)}
</p>
)}
</CardContent>
{/* Pagination Footer */}
{auditLogs.length > 0 && (
<CardFooter className="flex items-center justify-between border-t border-gray-200 dark:border-gray-600 pt-4">
<div className="text-xs text-gray-600 dark:text-gray-400">
{t("common.showing", "Showing")} {(currentPage - 1) * pageSize + 1}-
{Math.min(currentPage * pageSize, totalCount)} {t("common.of", "of")} {totalCount}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={!hasPrevPage || loading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
{currentPage} / {totalPages || 1}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasNextPage || loading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
);
};
export default RecentActivitiesCard;

View File

@@ -0,0 +1,351 @@
// components/record-tag-selector.tsx
"use client";
import * as React from "react";
import { X, Check, ChevronsUpDown, Tag, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/common/ui/command";
// ─── Types ─────────────────────────────────────────────
export interface TagSelectorProps {
/** Unit ID to fetch tags from */
unitId: string;
/** Currently selected tag IDs */
selectedTagIds: string[];
/** Callback when selection changes */
onChange: (tagIds: string[]) => void;
/** Placeholder text */
placeholder?: string;
/** Allow multiple selection */
multiple?: boolean;
/** Disable the selector */
disabled?: boolean;
/** Custom className */
className?: string;
/** Maximum number of tags to show before collapsing */
maxDisplayTags?: number;
/** Optional error state */
error?: string;
}
// ─── Component ─────────────────────────────────────────
export function RecordTagSelector({
unitId,
selectedTagIds,
onChange,
placeholder = "Select tags...",
multiple = true,
disabled = false,
className,
maxDisplayTags = 3,
error,
}: TagSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList, isErrorRecordTagsList } =
useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
// Toggle tag selection
const toggleTag = React.useCallback(
(tagId: string) => {
if (multiple) {
onChange(
selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId],
);
} else {
onChange(selectedTagIds.includes(tagId) ? [] : [tagId]);
setOpen(false);
}
},
[multiple, onChange, selectedTagIds],
);
// Remove a specific tag
const removeTag = React.useCallback(
(e: React.MouseEvent, tagId: string) => {
e.stopPropagation();
onChange(selectedTagIds.filter((id) => id !== tagId));
},
[onChange, selectedTagIds],
);
// Clear all selections
const clearAll = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange([]);
},
[onChange],
);
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoadingRecordTagsList}
className={cn(
"w-full justify-between min-h-[40px] h-auto px-3 py-2",
!multiple && selectedTags.length > 0 && "justify-start gap-2",
error && "border-destructive ring-destructive",
"hover:bg-accent",
)}
>
{isLoadingRecordTagsList ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t("common.loading")}</span>
</div>
) : selectedTags.length === 0 ? (
<span className="text-muted-foreground text-sm">
{placeholder}
</span>
) : multiple ? (
<div className="flex flex-wrap items-center gap-1.5 flex-1">
{selectedTags.slice(0, maxDisplayTags).map((tag: any) => (
<Badge
key={tag.id}
variant="secondary"
className="gap-1 px-2 py-0.5 text-xs font-medium cursor-default"
style={{
backgroundColor: tag.color ? `${tag.color}20` : undefined,
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={(e) => removeTag(e, tag.id)}
/>
</Badge>
))}
{selectedTags.length > maxDisplayTags && (
<Badge variant="secondary" className="text-xs">
+{selectedTags.length - maxDisplayTags}
</Badge>
)}
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<span className="text-sm">
{localizedName(selectedTags[0].name)}
</span>
</div>
)}
<div className="flex items-center gap-1 shrink-0 ml-2">
{selectedTags.length > 0 && !disabled && (
<X
className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={clearAll}
/>
)}
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t("common.search") || "Search tags..."}
/>
<CommandList>
<CommandEmpty>
{isErrorRecordTagsList ? (
<div className="py-6 text-center text-sm text-destructive">
{t("common.errorLoading")}
</div>
) : (
t("common.noResults") || "No tags found."
)}
</CommandEmpty>
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<CommandItem
key={tag.id}
value={tag.id}
onSelect={() => toggleTag(tag.id)}
className="cursor-pointer"
>
<div className="flex items-center gap-3 flex-1">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50",
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<span className="flex-1 text-sm">
{localizedName(tag.name)}
</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Single Select Variant ─────────────────────────────
export function RecordTagSelectorSingle(
props: Omit<TagSelectorProps, "multiple" | "maxDisplayTags">,
) {
return <RecordTagSelector {...props} multiple={false} />;
}
// ─── Display Only (Read) Component ─────────────────────
export interface TagListProps {
tagIds: string[];
unitId: string;
className?: string;
size?: "sm" | "md" | "lg";
}
export function RecordTagList({
tagIds,
unitId,
className,
size = "md",
}: TagListProps) {
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => tagIds.includes(tag.id));
const sizeClasses = {
sm: "text-[10px] px-1.5 py-0",
md: "text-xs px-2 py-0.5",
lg: "text-sm px-2.5 py-1",
};
if (isLoadingRecordTagsList) {
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
}
if (selectedTags.length === 0) {
return <span className="text-xs text-muted-foreground"></span>;
}
return (
<div className={cn("flex flex-wrap gap-1.5", className)}>
{selectedTags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className={cn("font-medium gap-1.5", sizeClasses[size])}
>
<span className="h-1.5 w-1.5 rounded-full" />
{localizedName(tag.name)}
</Badge>
))}
</div>
);
}
// ─── Create Tag Dialog Integration ─────────────────────
export interface TagSelectorWithCreateProps extends TagSelectorProps {
onCreateTag?: (name: string) => void;
isCreatingTag?: boolean;
}
export function RecordTagSelectorWithCreate({
onCreateTag,
isCreatingTag,
...props
}: TagSelectorWithCreateProps) {
const { t } = useTranslation();
const [newTagName, setNewTagName] = React.useState("");
const handleCreate = React.useCallback(() => {
if (newTagName.trim() && onCreateTag) {
onCreateTag(newTagName.trim());
setNewTagName("");
}
}, [newTagName, onCreateTag]);
return (
<div className="space-y-2">
<RecordTagSelector {...props} />
{onCreateTag && (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder={t("tags.createPlaceholder") || "Create new tag..."}
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
/>
</div>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={handleCreate}
disabled={!newTagName.trim() || isCreatingTag}
>
{isCreatingTag ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("common.create") || "Create"
)}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,375 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/common/ui/card";
import { Stamp, X, Plus } from "lucide-react";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Button } from "@/shared/common/ui/button";
import { useEffect, useState, useRef } from "react";
import { useSeal } from "@/user-management/hooks/useSeal";
import {
CreateSealPayload,
sealService,
} from "@/user-management/services/api/sealService";
import { toast } from "sonner";
import { FilePreview } from "./ContentManagement";
import { presignedAxios } from "@/shared/services/presignedAxios";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
interface Props {
unitId: string;
}
const SealCard = ({ unitId }: Props) => {
const {
createSeal,
isCreatingSeal,
isLoadingSeals,
deleteSeal,
getSealsByUnitId,
} = useSeal();
const [file, setFile] = useState<File>();
const [previews, setPreviews] = useState<FilePreview[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [sealName, setSealName] = useState("");
const [sealNameError, setSealNameError] = useState("");
const MAX_SIZE = 1 * 1024 * 1024;
const { data: seals } = getSealsByUnitId(unitId);
// -------------------------------
// Fetch existing seals
// -------------------------------
useEffect(() => {
let isMounted = true;
const fetchPresigned = async () => {
if (!seals?.items) return;
const filtered = seals.items.filter(
(s) => s.uploadedSuccessfully && s.isCurrent
);
const resolved = await Promise.all(
filtered.map(async (seal): Promise<FilePreview | null> => {
try {
const sealDetail = await sealService.getSeal(seal.id);
const presigned = sealDetail.data.presigned;
if (!presigned) return null;
return {
type: "seal",
file: new File([], seal.fileInfo.fileName),
url: presigned,
uploadedAt: new Date(seal.createdAt),
id: seal.id,
};
} catch {
return null;
}
})
);
const filteredPreviews: FilePreview[] = resolved.filter(
(p): p is FilePreview => p !== null
);
if (isMounted) {
setPreviews(filteredPreviews);
}
};
fetchPresigned();
return () => {
isMounted = false;
previews.forEach((p) => {
if (!p.id && p.url.startsWith("blob:")) {
URL.revokeObjectURL(p.url);
}
});
};
}, [seals?.items]);
// -------------------------------
// File validation + preview
// -------------------------------
const handleFileUpload = (selectedFile: File) => {
if (selectedFile.type !== "image/png") {
toast.error(t("contentManagement.onlyPngAllowed"));
return;
}
if (selectedFile.size > MAX_SIZE) {
toast.error("Image size should be less than 1 MB");
return;
}
const tempUrl = URL.createObjectURL(selectedFile);
const img = new Image();
img.onload = () => {
if (img.width !== img.height) {
toast.error(t("contentManagement.onlySquareAllowed"));
URL.revokeObjectURL(tempUrl);
return;
}
setFile(selectedFile);
setSealName("");
setSealNameError("");
setPreviews([
{
type: "seal",
url: tempUrl,
file: selectedFile,
uploadedAt: new Date(),
id: null,
},
]);
};
img.onerror = () => {
toast.error(t("contentManagement.invalidImage"));
URL.revokeObjectURL(tempUrl);
};
img.src = tempUrl;
};
// -------------------------------
// Upload flow (CREATE + PUT)
// -------------------------------
const uploadSeal = async () => {
if (!file) {
toast.error(t("contentManagement.noFileSelected"));
return;
}
if (!sealName.trim()) {
setSealNameError(t("contentManagement.sealNameMsg"));
return;
}
try {
// Step 1: Create seal (get presigned URL)
const payload: CreateSealPayload = {
fileInfo: {
fileName: file.name,
contentType: file.type,
size: file.size,
originalname: file.name,
},
name: {
am: sealName,
en: sealName,
},
unitId,
};
const data = await createSeal(payload);
// Step 2: Upload file
setIsUploading(true);
setUploadProgress(0);
await presignedAxios.put(data.presigned, file, {
headers: {
"Content-Type": file.type,
},
});
// await updateSealStatus({
// id: data.id,
// updateSealStatusPayload: {
// id: data.id,
// parentId: data.id,
// uploadedSuccessfully: true,
// },
// });
setIsUploading(false);
setUploadProgress(100);
// Reset UI
setFile(undefined);
setSealName("");
setSealNameError("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
queryClient.invalidateQueries({ queryKey: ["seal"] });
toast.success(t("contentManagement.sealSuccessMsg"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealErrorMsg"));
setIsUploading(false);
setUploadProgress(0);
setFile(undefined);
}
};
// -------------------------------
// Delete seal
// -------------------------------
const handleRemove = async (id: string | null) => {
if (!id) return;
try {
await deleteSeal(id);
setPreviews((prev) => prev.filter((p) => p.id !== id));
toast.success(t("contentManagement.sealRemove"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealRemove"));
}
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const isProcessing = isCreatingSeal || isUploading;
return (
<Card className="dark:border-gray-700 dark:bg-gray-800">
<CardHeader>
<CardTitle className="flex items-center dark:text-white">
<Stamp className="h-5 w-5 mr-2" />
{t("contentManagement.seal")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("contentManagement.uploadSealMsg")} (PNG, max 1MB)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<Label htmlFor="seal" className="dark:text-gray-200">{t("contentManagement.sealImage")}</Label>
<Button
variant="outline"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessing}
>
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addSeal")}
</Button>
</div>
<Input
ref={fileInputRef}
type="file"
accept=".png,image/png"
className="hidden"
onChange={(e) =>
e.target.files?.[0] && handleFileUpload(e.target.files[0])
}
disabled={isProcessing}
/>
{/* PREVIEWS */}
{previews.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{previews.map((preview) => (
<div
key={preview?.id || preview?.url}
className="border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600"
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-muted-foreground dark:text-gray-400">
{formatDate(preview?.uploadedAt)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(preview?.id || null)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30"
disabled={isLoadingSeals}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
<img
src={preview.url}
alt="Seal"
className="max-h-32 mx-auto object-contain"
/>
</div>
))}
</div>
) : (
<div className="text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30">
<p className="text-muted-foreground dark:text-gray-400">
{t("contentManagement.noSealMsg")}
</p>
<p className="text-sm text-muted-foreground dark:text-gray-500 mt-1">
{t("contentManagement.addSealInstruction")}
</p>
</div>
)}
{/* NAME INPUT */}
{file && (
<div className="space-y-2">
<Label htmlFor="sealName" className="dark:text-gray-200">{t("contentManagement.sealName")}</Label>
<Input
value={sealName}
onChange={(e) => {
setSealName(e.target.value);
setSealNameError("");
}}
placeholder="Enter seal name"
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{sealNameError && (
<p className="text-sm text-red-500">{sealNameError}</p>
)}
</div>
)}
{/* UPLOAD BUTTON (MAIN UX CONTROL) */}
{file && (
<Button
className="w-full bg-primary hover:bg-primary/90"
onClick={uploadSeal}
disabled={isProcessing || !file}
>
{isCreatingSeal && "Creating seal..."}
{isUploading && `Uploading ${uploadProgress}%`}
{!isProcessing && t("contentManagement.saveSeal")}
</Button>
)}
</CardContent>
</Card>
);
};
export default SealCard;

View File

@@ -0,0 +1,578 @@
// components/tag-based-reference-numbers.tsx
"use client";
import * as React from "react";
import { Plus, Trash2, Loader2, AlertCircle, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRecordTagPrefixes } from "@/user-management/hooks/useRecordTagPrefixes";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { Label } from "@/shared/common/ui/label";
import { useQueryClient } from "@tanstack/react-query";
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { cn } from "@/shared/lib/utils";
import { RecordTagSelector } from "./RecordTagSelector";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Badge } from "@/shared/common/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/shared/common/ui/pagination";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useLocalizedName } from "@/shared/common/localizedName";
import { CreatePrefixPayload } from "@/user-management/services/api/prefixSuffixService";
// ─── Types ─────────────────────────────────────────────
export interface TagBasedReferenceNumbersProps {
unitId: string;
className?: string;
}
export interface PrefixItem {
id: string;
prefix: string;
suffix?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
// ─── Component ─────────────────────────────────────────
export function TagBasedReferenceNumbers({
unitId,
className,
}: TagBasedReferenceNumbersProps) {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const queryClient = useQueryClient();
const [selectedTagId, setSelectedTagId] = React.useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
const [deleteTargetId, setDeleteTargetId] = React.useState<string | null>(
null,
);
const [countPopup, setCountPopup] = React.useState<{
itemId: string | null;
currentCount: number;
} | null>(null);
const [countValue, setCountValue] = React.useState("");
const [currentPage, setCurrentPage] = React.useState(1);
const pageSize = 10;
const skip = (currentPage - 1) * pageSize;
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
const {
prefixes,
total,
isLoadingPrefixes,
isErrorPrefixes,
createPrefix,
isCreatingPrefix,
deletePrefix,
isDeletingPrefix,
} = useRecordTagPrefixes({
unitId,
recordTagId: selectedTagId || undefined,
skip,
take: pageSize,
});
const totalPages = Math.ceil(total / pageSize);
// Reset page when tag changes
React.useEffect(() => {
setCurrentPage(1);
}, [selectedTagId]);
// Get selected tag details for display
const selectedTag = React.useMemo(() => {
return recordTagsList?.items?.find((tag) => tag.id === selectedTagId);
}, [recordTagsList, selectedTagId]);
// Handle add prefix
const handleAddPrefix = React.useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const nameAm = (formData.get("nameAm") as string)?.trim();
const nameEn = (formData.get("nameEn") as string)?.trim();
if (!nameAm || !nameEn) return;
const payload: CreatePrefixPayload = {
name: { am: nameAm, en: nameEn },
unitId,
isForCC: false,
recordTagId: selectedTagId || undefined,
};
createPrefix(payload, {
onSuccess: () => {
setIsAddDialogOpen(false);
},
});
},
[createPrefix, selectedTagId, unitId],
);
// Handle delete
const handleDelete = React.useCallback(() => {
if (deleteTargetId) {
deletePrefix(deleteTargetId, {
onSuccess: () => setDeleteTargetId(null),
});
}
}, [deletePrefix, deleteTargetId]);
const getCount = (item: any): number =>
item.recordSequences?.[0]?.count ?? item.count ?? 0;
const getSequenceId = (item: any): string | null =>
item.recordSequences?.[0]?.id ?? item.sequenceId ?? null;
const openCountPopup = (item: any) => {
console.log("[TagBasedRef] prefix item:", item);
const currentCount = getCount(item);
const sequenceId = getSequenceId(item);
setCountPopup({ itemId: sequenceId, currentCount });
setCountValue(String(currentCount));
};
const closeCountPopup = () => {
setCountPopup(null);
setCountValue("");
queryClient.invalidateQueries({ queryKey: ["recordTagPrefixes", unitId, selectedTagId] });
};
const handleCountSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!countPopup || !countPopup.itemId) return;
const newCount = parseInt(countValue, 10);
if (isNaN(newCount) || newCount < 0) return;
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
closeCountPopup();
});
};
return (
<div className={cn("space-y-6", className)}>
{/* ─── Header Section: Tag Selector + Add Button ─── */}
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-4">
<div className="flex-1 w-full sm:w-auto space-y-2">
<Label className="text-sm font-medium">
{t("nav.selectTag") || "Select Record Tag"}
</Label>
<RecordTagSelector
unitId={unitId}
selectedTagIds={selectedTagId ? [selectedTagId] : []}
onChange={(ids) => setSelectedTagId(ids[0] || "")}
placeholder={
t("nav.selectTag") || "Choose a tag to view prefixes..."
}
multiple={false}
disabled={isLoadingRecordTagsList}
/>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button
className="shrink-0"
disabled={!selectedTagId || isLoadingPrefixes}
>
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addPrefix") || "Add Prefix"}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("contentManagement.addPrefix") || "Add New Prefix"}
</DialogTitle>
</DialogHeader>
<form
id="add-prefix-form"
onSubmit={handleAddPrefix}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
name="nameAm"
placeholder={t("contentManagement.amharicName")}
required
autoFocus
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
name="nameEn"
placeholder={t("contentManagement.englishName")}
required
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</form>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsAddDialogOpen(false)}
>
{t("common.cancel") || "Cancel"}
</Button>
<Button
type="submit"
form="add-prefix-form"
disabled={isCreatingPrefix}
>
{isCreatingPrefix && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
{t("common.save") || "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* ─── Selected Tag Info ─── */}
{selectedTag && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Showing prefixes for:</span>
<Badge variant="outline">{localizedName(selectedTag.name)}</Badge>
<span className="text-xs">({total} items)</span>
</div>
)}
{/* ─── Table Section ─── */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">
{t("common.no") || "#"}
</TableHead>
<TableHead>{t("contentManagement.amharicName")}</TableHead>
<TableHead>{t("contentManagement.englishName")}</TableHead>
<TableHead>
{t("contentManagement.createdAt") || "Created"}
</TableHead>
<TableHead className="w-[100px]">
{t("contentManagement.count") || "Count"}
</TableHead>
<TableHead className="w-[100px] text-right">
{t("common.actions") || "Actions"}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Loading State */}
{isLoadingPrefixes && (
<>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell>
<Skeleton className="h-4 w-8" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8 ml-auto" />
</TableCell>
</TableRow>
))}
</>
)}
{/* Error State */}
{isErrorPrefixes && !isLoadingPrefixes && (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<div className="flex flex-col items-center gap-2 text-destructive">
<AlertCircle className="h-8 w-8" />
<p className="text-sm">
{t("common.errorLoading") || "Failed to load prefixes"}
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
{t("common.retry") || "Retry"}
</Button>
</div>
</TableCell>
</TableRow>
)}
{/* Empty States */}
{!isLoadingPrefixes && !isErrorPrefixes && (
<>
{!selectedTagId && (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<span className="text-2xl">🏷</span>
<p className="text-sm">
{t("nav.selectTag") ||
"Select a tag above to view its prefixes"}
</p>
</div>
</TableCell>
</TableRow>
)}
{selectedTagId && prefixes.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Plus className="h-8 w-8 opacity-50" />
<p className="text-sm">
{t("nav.emptyState") ||
"No prefixes found for this tag"}
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAddDialogOpen(true)}
>
{t("nav.addNew") || "Add your first prefix"}
</Button>
</div>
</TableCell>
</TableRow>
)}
{/* Data Rows */}
{prefixes.map((prefix, index: number) => {
const count = getCount(prefix);
return (
<TableRow key={prefix.id} className="group">
<TableCell className="text-muted-foreground text-sm">
{skip + index + 1}
</TableCell>
<TableCell className="font-medium">
{prefix.name?.am || (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell>
{prefix.name?.en || (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(prefix.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<button
onClick={() => openCountPopup(prefix)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer text-sm"
>
{count}
</button>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => setDeleteTargetId(prefix.id)}
disabled={isDeletingPrefix}
title="Delete this prefix"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</>
)}
</TableBody>
</Table>
</div>
{/* ─── Pagination ─── */}
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
className={cn(
currentPage === 1 && "pointer-events-none opacity-50",
)}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
isActive={currentPage === page}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
className={cn(
currentPage === totalPages &&
"pointer-events-none opacity-50",
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
{/* ─── Count Update Popup ─── */}
{countPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold dark:text-white">
{t("contentManagement.updateCount")}
</h3>
<button
onClick={closeCountPopup}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleCountSubmit}>
<div className="space-y-2 mb-4">
<Label htmlFor="countValue" className="dark:text-gray-200">
{t("contentManagement.value")} *
</Label>
<Input
id="countValue"
type="number"
min="0"
value={countValue}
onChange={(e) => setCountValue(e.target.value)}
placeholder={t("contentManagement.enterValue")}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("contentManagement.currentCount")}: {countPopup.currentCount}
</p>
{!countPopup.itemId && (
<p className="text-xs text-red-500">
No sequence found for this prefix. Use it in a record first.
</p>
)}
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={closeCountPopup}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!countPopup.itemId}>
{t("common.submit")}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ─── Delete Confirmation ─── */}
<AlertDialog
open={!!deleteTargetId}
onOpenChange={(open) => !open && setDeleteTargetId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("prefixes.deleteConfirmTitle") || "Delete Tag-Based Reference Prefix?"}
</AlertDialogTitle>
<AlertDialogDescription>
{t("prefixes.deleteConfirmDescription") ||
"Are you sure you want to delete this prefix? This action cannot be undone and will permanently remove this prefix from the system."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingPrefix}>
{t("common.cancel") || "Cancel"}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeletingPrefix}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingPrefix && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
{t("common.delete") || "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { RecordTagForm } from "./RecordTagForm";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
row: RecordTag;
unitId: string;
}
export default function RecordTagActionsCell({ row, unitId }: Props) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); // confirmation dialog
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { deleteRecordTag, refetchRecordTagsList } = useRecordTags({ unitId });
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// --- Handle Delete ---
const confirmDelete = () => {
setIsDeleteLoading(true);
deleteRecordTag(row.id, {
onSuccess: () => {
refetchRecordTagsList();
setIsDeleteOpen(false);
},
onError: (error) => {
handleError(error);
},
onSettled: () => {
setIsDeleteLoading(false);
},
});
};
return (
<>
<div className="flex items-center gap-2">
{/* Edit Button */}
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
<Pencil className="h-4 w-4" />
</Button>
{/* Delete Button (opens confirm) */}
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteOpen(true)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Record Tag</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="edit"
id={row.id}
defaultValues={{
nameAm: row.name?.am ?? "",
nameEn: row.name?.en ?? "",
key: row.key,
unitId: row.unitId,
}}
onSuccess={() => {
setIsEditOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete this record tag?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The record tag <b>{row.name?.en}</b>{" "}
will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleteLoading}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleteLoading}
className="bg-red-600 hover:bg-red-700">
{isDeleteLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,153 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import {
CreateRecordTagPayload,
UpdateRecordTagPayload,
} from "@/user-management/dto/recordTags/recordTags.type";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
const recordTagSchema = z.object({
nameAm: z.string().min(1, "Amharic name is required"),
nameEn: z.string().min(1, "English name is required"),
key: z.string().min(1, "Key is required"),
unitId: z.string().min(1, "Unit is required"),
});
export type RecordTagFormValues = z.infer<typeof recordTagSchema>;
interface RecordTagFormProps {
defaultValues?: Partial<RecordTagFormValues>;
unitId: string;
mode: "create" | "edit";
id?: string; // required if edit
onSuccess?: () => void;
}
export function RecordTagForm({
defaultValues,
unitId,
mode,
id,
onSuccess,
}: RecordTagFormProps) {
const form = useForm<RecordTagFormValues>({
resolver: zodResolver(recordTagSchema),
defaultValues: {
nameAm: defaultValues?.nameAm ?? "",
nameEn: defaultValues?.nameEn ?? "",
key: defaultValues?.key ?? "",
unitId: defaultValues?.unitId ?? unitId,
},
});
const {
createRecordTag,
updateRecordTag,
isCreatingRecordTag,
isUpdatingRecordTag,
} = useRecordTags({ unitId });
const handleSubmit = (values: RecordTagFormValues) => {
if (mode === "create") {
const payload: CreateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
createRecordTag(payload, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
});
} else if (mode === "edit" && id) {
const payload: UpdateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
updateRecordTag(
{ id, payload },
{
onSuccess: () => {
onSuccess?.();
},
}
);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>Amharic Name</FormLabel>
<FormControl>
<Input placeholder="Enter Amharic name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English Name</FormLabel>
<FormControl>
<Input placeholder="Enter English name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input placeholder="Unique key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isCreatingRecordTag || isUpdatingRecordTag}
>
{mode === "create"
? isCreatingRecordTag
? "Creating..."
: "Create"
: isUpdatingRecordTag
? "Updating..."
: "Update"}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTagColumnDefn } from "./RecordTagsColumn";
import { RecordTagForm } from "./RecordTagForm";
export default function RecordTagsManagement({ unitId }: { unitId: string }) {
const [pageIndex, setPageIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
// --- Record Tags Query ---
const { recordTagsList, isLoadingRecordTagsList, refetchRecordTagsList } =
useRecordTags({
unitId: unitId ?? "",
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
if (isLoadingRecordTagsList) {
return <div>{t("loading")}</div>;
}
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold">
{t("recordTag.title")}
</CardTitle>
{/* Create Button (opens modal) */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{t("recordTag.createNew")}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("recordTag.createTitle")}</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="create"
onSuccess={() => {
setIsOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
</CardHeader>
{/* Record Tags Table */}
<CardContent className="px-0">
<AdvancedTable
columns={RecordTagColumnDefn(unitId)}
data={recordTagsList?.items || []}
tableName={t("recordTag.tableName")}
toolBarPosition="right"
itemCount={recordTagsList?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { ColumnDef } from "@tanstack/react-table";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { format } from "date-fns";
import { t } from "i18next";
import RecordTagActionsCell from "./RecordTagActionsCell";
import { useLocalizedName } from "@/shared/common/localizedName";
export const RecordTagColumnDefn = (unitId: string): ColumnDef<RecordTag>[] => {
return [
{
accessorKey: "name",
header: () => t("recordTag.name"),
cell: ({ row }) => {
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
{
accessorKey: "key",
header: () => t("recordTag.key"),
cell: ({ row }) => {
const key = row.original?.key;
return <span>{key || "--"}</span>;
},
},
{
accessorKey: "createdAt",
header: () => t("recordTag.CreatedAt"),
cell: ({ row }) => {
const date = row.original?.createdAt;
return (
<span>
{date ? format(new Date(date), "MMM d, yyyy HH:mm") : "--"}
</span>
);
},
},
{
id: "actions",
header: () => t("recordTag.Actions"),
cell: ({ row }) => (
<RecordTagActionsCell row={row.original} unitId={unitId} />
),
},
];
};

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function useDebounceCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
return ((...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
const newTimeoutId = setTimeout(() => {
callback(...args);
}, delay);
setTimeoutId(newTimeoutId);
}) as T;
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { t } from "i18next";
type ActionsColumnProps = {
row: PositionTypeDto;
};
const ActionsColumn: React.FC<ActionsColumnProps> = ({ row }) => {
const navigate = useNavigate();
const [openDialog, setOpenDialog] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { deletePositionType } = usePositionTypes({ id: "" });
const handleDeleteClick = (id: string) => {
setDeletingId(id);
setOpenDialog(true);
};
const handleDeleteConfirm = async () => {
if (!deletingId) return;
await deletePositionType.mutateAsync(deletingId);
setOpenDialog(false);
setDeletingId(null);
};
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
navigate(`/user-management/position-management/edit/${row.id}`)
}
>
{t("common.Edit")}
</Button>
<AlertDialog open={openDialog} onOpenChange={setOpenDialog}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => handleDeleteClick(row.id)}
>
{t("userRecord.Delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("contentManagement.delMsg")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.delMsg2")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setOpenDialog(false);
setDeletingId(null);
}}
>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={deletePositionType.isPending}
>
{deletePositionType.isPending
? t("organization.deleting")
: t("organization.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default ActionsColumn;

View File

@@ -0,0 +1,455 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/shared/common/ui/form";
import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { SingleSelect } from "@/shared/common/ui/single-select";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import i18n from "@/i18n";
import { PermissionSearch } from "./PermissionSearch";
import { useApplications } from "@/user-management/hooks/useApplications";
import { useQuery, useQueryClient } from "@tanstack/react-query";
const formSchema = z.object({
nameAm: z.string().min(2),
nameEn: z.string().min(2),
permissions: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
export interface CreatePositionFormProps {
mode?: "create" | "edit";
positionTypeId?: string;
initialValues?: {
nameAm: string;
nameEn: string;
unitId: string;
key?: string;
};
onSuccess?: () => void;
onCancel?: () => void;
}
export const CreatePositionForm = ({
mode = "create",
positionTypeId,
initialValues,
onSuccess,
onCancel,
}: CreatePositionFormProps = {}) => {
const navigate = useNavigate();
const {
createPositionType,
updatePositionType,
positionTypes,
isLoading: isLoadingPositionTypes,
} = usePositionTypes();
const { user } = useAuth();
const { getList, getById } = useUnit();
const localizedName = useLocalizedName();
const userOrganizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>(
userOrganizationId ?? "",
);
const [selectedUnitId, setSelectedUnitId] = useState<string>(
initialValues?.unitId ?? "",
);
const [selectedApplicationId, setSelectedApplicationId] =
useState<string>("");
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
const [isCopying, setIsCopying] = useState(false);
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
const hasLoadedEditData = useRef(false);
const lang = i18n.language;
const { applications, isLoading: isLoadingApplications } = useApplications();
const queryClient = useQueryClient();
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
{ take: 3000 },
);
const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
selectedOrganizationId,
{ take: 3000, skip: 0 },
);
const organizationOptions = useMemo(
() =>
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({
value: org.id,
label: localizedName(org.name) || org.id,
})),
[organizationsResponse, localizedName],
);
const unitOptions = useMemo(
() =>
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({
value: unit.id,
label: localizedName(unit.name) || unit.id,
})),
[unitsResponse, localizedName],
);
const {
data: editUnitResponse,
isSuccess: isUnitSuccess,
isError: isUnitError,
} = getById(initialValues?.unitId ?? "");
const {
data: permissionsResponse,
isSuccess: isPermissionsSuccess,
isError: isPermissionsError,
} = useQuery({
queryKey: ["position-type-permissions", positionTypeId],
queryFn: () =>
positionTypePermissionService.getPermissionsByPositionTypeId(
positionTypeId!,
),
enabled: mode === "edit" && !!positionTypeId,
});
// Reset the selected unit when the organization changes so a unit from a
// different org can't be submitted by mistake.
useEffect(() => {
if (mode === "edit") return;
setSelectedUnitId("");
}, [selectedOrganizationId, mode]);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
nameAm: initialValues?.nameAm ?? "",
nameEn: initialValues?.nameEn ?? "",
permissions: [],
},
});
useEffect(() => {
if (mode !== "edit" || !initialValues || !positionTypeId) return;
if (hasLoadedEditData.current) return;
const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError;
const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
if (isUnitDone && isPermissionsDone) {
hasLoadedEditData.current = true;
const unit = editUnitResponse?.data;
if (unit) {
setSelectedOrganizationId(unit.organizationId);
setSelectedUnitId(unit.id);
} else if (initialValues.unitId) {
setSelectedUnitId(initialValues.unitId);
}
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
form.reset({
nameAm: initialValues.nameAm,
nameEn: initialValues.nameEn,
permissions: ids,
});
setIsLoadingEditData(false);
}
}, [
mode,
initialValues,
positionTypeId,
isUnitSuccess,
isUnitError,
isPermissionsSuccess,
isPermissionsError,
editUnitResponse,
permissionsResponse,
form,
]);
const handlePermissionChange = (permissionId: string, checked: boolean) => {
const currentPermissions = form.getValues("permissions");
if (checked) {
form.setValue("permissions", [...currentPermissions, permissionId]);
} else {
form.setValue(
"permissions",
currentPermissions.filter((id) => id !== permissionId),
);
}
};
const handleCopyFrom = async (positionTypeId: string) => {
setCopyFromPositionId(positionTypeId);
if (!positionTypeId) {
form.setValue("permissions", []);
return;
}
setIsCopying(true);
try {
const response =
await positionTypePermissionService.getPermissionsByPositionTypeId(
positionTypeId,
);
const ids = response.data.items?.map((p) => p.id) ?? [];
form.setValue("permissions", ids);
} catch {
toast.error(t("contentManagement.copyPermissionsFailed"));
} finally {
setIsCopying(false);
}
};
const onSubmit = async (values: FormValues) => {
try {
if (!selectedUnitId) {
toast.error(t("organization.selectUnit"));
return;
}
const payload = {
name: {
am: values.nameAm,
en: values.nameEn,
},
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
unitId: selectedUnitId,
};
let targetId = positionTypeId;
if (mode === "edit" && positionTypeId) {
await updatePositionType.mutateAsync({
id: positionTypeId,
data: payload,
});
} else {
const response = await createPositionType.mutateAsync(payload);
targetId = response.data.id;
}
if (targetId && values.permissions.length > 0) {
await positionTypePermissionService.assignPermissionsToPositionType({
firstId: targetId,
secondIds: values.permissions,
});
}
queryClient.invalidateQueries({
queryKey: ["position-type"],
});
queryClient.invalidateQueries({ queryKey: ["position-types"] });
queryClient.invalidateQueries({
queryKey: ["position-type-permissions"],
});
toast.success(t("contentManagement.permissionSuccess"));
if (onSuccess) {
onSuccess();
} else {
navigate("/user-management/position-management");
}
} catch {
toast.error(t("contentManagement.permissionSuccess"));
}
};
if (isLoadingEditData) {
return (
<div className="py-8 text-center text-muted-foreground">
{t("common.loading")}
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.englishName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. HR Coordinator" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.amharicName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. ሰብል አያት" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ✅ Organization (searchable, all orgs) */}
<div className="mb-4 w-full">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("organization.organization") || "Organization"}
</label>
<SingleSelect
options={organizationOptions}
value={selectedOrganizationId}
onValueChange={setSelectedOrganizationId}
placeholder={
isLoadingOrgs
? t("common.loading")
: t("organization.selectOrganization") ||
"Select an organization"
}
/>
</div>
{/* ✅ Unit Selector — searchable, scoped to picked org */}
<div className="mb-4 w-full">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("organization.selectUnit")}
</label>
<SingleSelect
options={unitOptions}
value={selectedUnitId}
onValueChange={setSelectedUnitId}
placeholder={
!selectedOrganizationId
? t("organization.selectOrganizationFirst") ||
"Select an organization first"
: isLoadingUnits
? t("common.loading")
: t("organization.selectUnit")
}
/>
</div>
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.selectApplication")}
</label>
<Select
value={selectedApplicationId}
onValueChange={(value) => setSelectedApplicationId(value)}
disabled={isLoadingApplications}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue placeholder="Select an Application" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app) => (
<SelectItem key={app.id} value={app.id}>
{lang === "en" ? app.name.en : app.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.copyPermissionsFrom")}
</label>
<Select
value={copyFromPositionId}
onValueChange={handleCopyFrom}
disabled={isLoadingPositionTypes || isCopying}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
isCopying
? t("common.loading")
: t("contentManagement.selectPositionToCopy")
}
/>
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{positionTypes.map((p: PositionTypeDto) => (
<SelectItem key={p.id} value={p.id}>
{localizedName(p.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("contentManagement.copyPermissionsHint")}
</p>
</div>
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.permission")}</FormLabel>
<PermissionSearch
selectedPermissions={field.value}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
if (onCancel) {
onCancel();
} else {
window.history.back();
}
}}>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={
createPositionType.isPending || updatePositionType.isPending
}>
{mode === "edit" ? t("delegation.update") : t("delegation.save")}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,71 @@
import EnhancedDynamicModal from '@/record-management/common/EnhancedDynamicModal';
import { usePositionTypes } from '@/user-management/hooks/usePositionTypes';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface DeletePositionTypesProps {
id: string;
onSuccess: () => void;
onClose: () => void;
}
const DeletePositionTypes = (
{
id,
onSuccess,
onClose
}: DeletePositionTypesProps
) => {
const { t } = useTranslation();
const { deletePositionType, refetch } = usePositionTypes({ id });
const handleDelete = useCallback(async()=>{
try{
await deletePositionType.mutateAsync(id);
await refetch();
onSuccess();
toast.success("Position type deleted successfully");
}catch(error) {
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
(error as any).statusCode === 400 &&
"response" in error &&
(error as any).response &&
"data" in (error as any).response &&
(error as any).response.data &&
"code" in (error as any).response.data &&
(error as any).response.data.code === "23503"
) {
toast.error(
"This Position Type is still in use and cannot be deleted."
);
} else {
toast.error("An unexpected error occurred.");
}
console.error("Error deleting position type:", error);
toast.error("Error deleting position type");
}
},[ deletePositionType, id ]);
return (
<EnhancedDynamicModal
open={true}
onClose={onClose}
title={t("userRecord.Delete Record")}
actionConfig={{
type: "delete",
onAction: handleDelete,
confirmText: t("userRecord.Confirm Delete"),
confirmVariant: "default",
}}
/>
);
};
export default DeletePositionTypes;

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useNavigate } from "react-router-dom";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useAuth } from "@/shared/context/AuthContext";
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { useApplications } from "@/user-management/hooks/useApplications";
import { PermissionSearch } from "./PermissionSearch";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
import { useLocalizedName } from "@/shared/common/localizedName";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { t } from "i18next";
export const EditPositionForm = ({ id }: { id: string }) => {
const navigate = useNavigate();
const { user } = useAuth();
const { getList } = useUnit();
const localizedName = useLocalizedName();
const { positionType, isLoadingSingle } = usePositionTypes({ id });
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const { applications, isLoading: isLoadingApplications } = useApplications();
const { data: unitsResponse } = getList(organizationId || "", {
take: 300,
skip: 0,
});
const [selectedApplicationId, setSelectedApplicationId] =
useState<string>("");
const [assignedPermissions, setAssignedPermissions] = useState<
PermissionDto[]
>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
useEffect(() => {
const load = async () => {
if (!positionType) return;
setIsLoadingPermissions(true);
try {
const assigned =
await positionTypePermissionService.getPermissionsByPositionTypeId(
positionType.id,
);
setAssignedPermissions(assigned.data.items ?? []);
} finally {
setIsLoadingPermissions(false);
}
};
load();
}, [positionType]);
if (isLoadingSingle) return <p>Loading...</p>;
if (!positionType) return null;
const unit = unitsResponse?.data?.items?.find(
(u: UnitDto) => u.id === positionType.unitId,
);
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
return (
<div className="space-y-6">
<div className="space-y-2">
<Label>{t("contentManagement.englishName")}</Label>
<Input value={positionType.name.en} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.amharicName")}</Label>
<Input value={positionType.name.am} disabled readOnly />
</div>
<div className="space-y-2">
<Label>Key</Label>
<Input value={positionType.key} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.selectApplication")}</Label>
<Select
value={selectedApplicationId}
onValueChange={(value) => setSelectedApplicationId(value)}
disabled={isLoadingApplications}
>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue placeholder="Select an Application" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app: any) => (
<SelectItem key={app.id} value={app.id}>
{localizedName(app.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("contentManagement.permission")}</Label>
{selectedApplicationId ? (
<PermissionSearch
selectedPermissions={assignedPermissions.map((perm) => perm.id)}
onPermissionChange={() => {
// view-only mode in edit form
}}
applicationId={selectedApplicationId}
disabled
/>
) : (
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
{isLoadingPermissions ? (
<div className="text-center py-4 text-gray-500">Loading...</div>
) : assignedPermissions.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{assignedPermissions.map((perm) => (
<li
key={perm.id}
className="capitalize text-sm py-1 px-2 rounded bg-muted/40"
>
{localizedName(perm.name)}
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/user-management/position-management")}
>
{t("common.Back")}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from "react";
import { Bell } from "lucide-react";
import { useNotifications } from "@/shared/hooks/useNotification";
export const NotificationBell = () => {
const { unseenCount } = useNotifications({ take: 1, skip: 0, orderBy: "createdAt:DESC" });
return (
<div className="relative cursor-pointer">
<Bell className="w-6 h-6" />
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full text-xs px-1">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</div>
);
};

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Input } from "@/shared/common/ui/input";
import { Checkbox } from "@/shared/common/ui/checkbox";
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { Search, Loader2 } from "lucide-react";
interface PermissionSearchProps {
selectedPermissions: string[];
onPermissionChange: (permissionId: string, checked: boolean) => void;
applicationId?: string;
disabled?: boolean;
}
const INITIAL_TAKE = 50; // Initial number of items to fetch
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
selectedPermissions,
onPermissionChange,
applicationId,
disabled = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50
const hasSetTotalCount = useRef(false); // Track if we've set the total count
const scrollContainerRef = useRef<HTMLDivElement>(null);
const localizedName = useLocalizedName();
/** ------------------ 1. Debounce Search ------------------ */
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
setTake(INITIAL_TAKE); // Reset to 50
hasSetTotalCount.current = false; // Reset the flag
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
/** ------------------ 2. Fetch Permissions ------------------ */
const { permissions, isPermissionsLoading } = usePermissionManager({
params: applicationId
? {
take,
skip: 0, // Always skip 0, we fetch everything at once
search: debouncedSearchTerm || undefined,
applicationId,
}
: undefined,
});
/** ------------------ 3. Update take to total count after first fetch ------------------ */
useEffect(() => {
if (
permissions?.count &&
!hasSetTotalCount.current &&
take !== permissions.count
) {
hasSetTotalCount.current = true;
setTake(permissions.count); // Fetch all items
}
}, [permissions?.count, take]);
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */
const filteredPermissions = useMemo(() => {
if (!permissions?.items?.length) return [];
if (!searchTerm.trim()) return permissions.items;
return permissions.items.filter((perm: PermissionDto) => {
const name = localizedName(perm.name).toLowerCase();
const key = perm.key.toLowerCase();
const search = searchTerm.toLowerCase();
return name.includes(search) || key.includes(search);
});
}, [permissions?.items, searchTerm, localizedName]);
/** ------------------ Render ------------------ */
return (
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder={t("contentManagement.searchPermissions")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Permission List Container */}
{!applicationId ? (
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
{t("contentManagement.selectApplicationToLoadPermissions") ||
"Select an application to load permissions."}
</div>
) : isPermissionsLoading ? (
<div className="flex justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<div
ref={scrollContainerRef}
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background"
>
{filteredPermissions.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{searchTerm
? t("contentManagement.noPermissionsFound")
: t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredPermissions.map((perm: PermissionDto) => (
<div
key={perm.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<Checkbox
checked={selectedPermissions.includes(perm.id)}
disabled={disabled}
onCheckedChange={(checked) => {
if (!disabled) onPermissionChange(perm.id, !!checked);
}}
/>
<label className="capitalize cursor-pointer font-normal text-sm">
{localizedName(perm.name)}
</label>
</div>
))}
</div>
)}
</div>
)}
{/* Footer Info */}
{filteredPermissions.length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("contentManagement.showingPermissions", {
count: filteredPermissions.length,
total: permissions?.count || 0,
})}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,276 @@
import { useEffect, useState, useMemo } from "react";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { Link } from "react-router-dom";
import { Plus } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
import { t } from "i18next";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useAuth } from "@/shared/context/AuthContext";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
export default function PositionManagement() {
const [pageIndex, setPageIndex] = useState(0);
const pageSize = 10;
const [isExporting, setIsExporting] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState("");
const { createConfiguration } = usePositionTypeConfiguration();
const { user } = useAuth();
const { getAccessibleList } = useUnit();
const organizationId = user?.employee?.[0]?.organizationId;
const { data: unitsResponse } = getAccessibleList(organizationId ?? "", {
take: 300,
skip: 0,
});
// Add state for selected unitId
// Default: if super_admin => "All", otherwise wait for units
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
useEffect(() => {
// If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
if (!selectedUnitId) {
if (unitsResponse?.data?.items?.length) {
setSelectedUnitId(unitsResponse.data.items[0].id);
} else {
setSelectedUnitId("All");
}
}
}, [unitsResponse, selectedUnitId]);
// Reset to first page whenever the search term or unit changes so users
// land on the first page of matches instead of an empty later page.
useEffect(() => {
setPageIndex(0);
}, [searchTerm, selectedUnitId]);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const {
positionTypeResponse,
isLoading,
positionTypeByUnitId,
refetch,
refetchPosition,
} = usePositionTypes({
params: {
take: 1000,
skip: 0,
orderBy: "updatedAt:DESC",
},
unitId: selectedUnitId === "All" ? undefined : selectedUnitId,
});
// Fetch position types without unitId for migration options
const {
positionTypeResponse: globalPositionTypes,
refetch: refetchGlobalPositionTypes,
} = usePositionTypes({
params: {
take: 1000, // Get all global position types
skip: 0,
orderBy: "updatedAt:DESC",
},
unitId: undefined, // Explicitly fetch position types without unitId
});
// Create a combined refetch function for the onDelete callback
const handlePositionTypeDeleted = async () => {
await Promise.all([
selectedUnitId === "All" ? refetch() : refetchPosition(),
refetchGlobalPositionTypes(),
]);
};
const handleToggle = async (
positionTypeId: string,
checked: boolean,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
) => {
if (!selectedUnitId || selectedUnitId === "All") return;
await createConfiguration({
positionTypeId,
timeframe: "yearly",
organizationId: organizationId!,
canReceiveRecord: field === "canReceiveRecord" ? checked : false,
canAssignRecord: field === "canAssignRecord" ? checked : false,
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false,
});
await handlePositionTypeDeleted();
};
// Create columns with positionTypeResponse
const columns = useMemo(
() =>
createPositionTypeColumns(
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId,
globalPositionTypes,
handlePositionTypeDeleted,
handlePositionTypeDeleted,
handleToggle, // ← pass toggle handler
selectedUnitId === "All", // ← isGlobal: hide toggle when "All"
),
[
selectedUnitId,
positionTypeResponse,
positionTypeByUnitId,
globalPositionTypes,
],
);
const allItems = useMemo(
() =>
(selectedUnitId === "All"
? positionTypeResponse?.items
: positionTypeByUnitId?.items) || [],
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items],
);
const filteredItems = useMemo(() => {
const trimmed = searchTerm.trim().toLowerCase();
if (!trimmed) return allItems;
return allItems.filter((item: any) => {
const en = (item?.name?.en || "").toLowerCase();
const am = (item?.name?.am || "").toLowerCase();
const key = (item?.key || "").toLowerCase();
return (
en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed)
);
});
}, [allItems, searchTerm]);
const paginatedItems = useMemo(() => {
const start = pageIndex * pageSize;
return filteredItems.slice(start, start + pageSize);
}, [filteredItems, pageIndex, pageSize]);
if (isLoading) {
return <div>{t("contentManagement.addUser")}</div>;
}
const exportTypes = () => {
setIsExporting(true);
positionTypeService
.getAll({
take: 3000,
})
.then((allPositionKeys) => {
// Get the position type keys
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
if (positionTypeKeys && positionTypeKeys.length > 0) {
// Convert the array of keys into a string, with each key on a new line
const fileContent = positionTypeKeys.join("\n");
// Create a Blob from the string content
const blob = new Blob([fileContent], { type: "text/plain" });
// Create a link element to trigger the download
const link = document.createElement("a");
// Create an object URL for the Blob
link.href = URL.createObjectURL(blob);
// Set the download attribute with a file name
link.download = "position_keys.txt";
// Programmatically trigger a click on the link to start the download
link.click();
// Clean up by revoking the object URL
URL.revokeObjectURL(link.href);
} else {
console.error("No position type keys found.");
}
setIsExporting(false);
});
};
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold ">
{t("contentManagement.permissionType")}
</CardTitle>
<Button onClick={exportTypes}>
{isExporting
? t("contentManagement.exporting")
: t("contentManagement.exportTypes")}
</Button>
</CardHeader>
{unitsResponse?.data?.items?.length > 0 && (
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
Select Unit
</label>
<Select
value={selectedUnitId}
onValueChange={(value) => setSelectedUnitId(value)}
>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm [&>span]:truncate">
<SelectValue placeholder="Select a Unit" />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="All">
All
</SelectItem>
{unitsResponse?.data.items.map((unit: UnitDto) => (
<SelectItem key={unit.id} value={unit.id}>
<span className="block truncate max-w-70">
{unit.name.en || unit.name.am}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CardContent className="px-0">
<AdvancedTable
columns={columns}
data={paginatedItems}
tableName="Positions"
toolBarPosition="right"
itemCount={filteredItems.length}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Link to="/user-management/position-management/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("contentManagement.newPermission")}
</Button>
</Link>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,393 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
} from "@/shared/common/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
import { t } from "i18next";
import PositionTypeMigrationModal from "./PostionTypeMigration";
import { CreatePositionForm } from "./CreatePositionForm";
import { toast } from "sonner";
import { useLocalizedName } from "@/shared/common/localizedName";
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
import { Switch } from "@/shared/common/ui/switch";
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService";
import { useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
interface PositionTypeResponse {
items: PositionTypeDto[];
count: number;
}
type ActionsCellProps = {
row: PositionTypeDto | PositionTypeConfigurationDto;
globalPositionTypes?: PositionTypeResponse;
onDelete?: () => void | Promise<void>;
onEdit?: () => void | Promise<void>;
onToggle?: (
positionTypeId: string,
checked: boolean,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
) => void | Promise<void>;
isGlobal?: boolean; // true when viewing "All" units — hide toggle
};
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
row,
globalPositionTypes,
onDelete,
onEdit,
onToggle,
isGlobal = false,
}) => {
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const localizedName = useLocalizedName();
const { handleError } = useErrorHandler(t);
const queryClient = useQueryClient();
// Use row.id as the positionTypeId for the configuration lookup
const {
configurations,
isLoadingConfigurations,
updateConfiguration,
isUpdatingConfiguration,
} = usePositionTypeConfiguration(
row?.id ?? null, // 👈 pass row.id as unitId
);
const configItem = configurations[0];
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false;
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false;
const invalidateConfig = () => {
queryClient.invalidateQueries({
queryKey: ["positionTypeConfigurations", row.id],
});
queryClient.invalidateQueries({
queryKey: ["positionTypeConfiguration", row.id],
});
};
// Create position type options from globalPositionTypes - only those WITHOUT unitId
const positionTypeOptions =
globalPositionTypes?.items
.filter((item) => !item.unitId)
.map((item) => ({
label: localizedName(item.name),
value: item.id,
})) || [];
// Only show migrate/delete actions if current row has a unitId
const canBeModified = !!row.unitId;
const handleView = () => {
navigate(`/user-management/position-management/edit/${row.id}`);
};
const handleMigrate = (e: Event) => {
e.preventDefault();
setDropdownOpen(false);
setShowMigrateDialog(true);
};
const handleEdit = (e: Event) => {
e.preventDefault();
setDropdownOpen(false);
setShowEditDialog(true);
};
const handleDelete = async () => {
try {
setIsDeleting(true);
await positionTypeService.delete(row.id);
toast.success(t("common.DeletedSuccessfully"));
setShowDeleteDialog(false);
if (onDelete) {
await onDelete();
}
} catch (error) {
handleError(error);
toast.error(t("common.FailedToDelete"));
} finally {
setIsDeleting(false);
}
};
const handleToggleChange = async (checked: boolean) => {
if (isGlobal) return;
try {
if (configItem?.id) {
await updateConfiguration({
id: configItem.id,
payload: {
organizationId: configItem.organizationId,
positionTypeId: configItem.positionTypeId,
timeframe: configItem.timeframe,
canReceiveRecord: checked,
},
});
} else {
await onToggle?.(row.id, checked, "canReceiveRecord");
}
toast.success(t("incomingRecord.UpdatedSuccessfully"));
invalidateConfig();
} catch (error) {
handleError(error);
toast.error(t("incomingRecord.FailedToUpdate"));
}
};
const handleAssignToggleChange = async (checked: boolean) => {
if (isGlobal) return;
try {
if (configItem?.id) {
await updateConfiguration({
id: configItem.id,
payload: {
organizationId: configItem.organizationId,
positionTypeId: configItem.positionTypeId,
timeframe: configItem.timeframe,
canAssignRecord: checked,
},
});
} else {
await onToggle?.(row.id, checked, "canAssignRecord");
}
toast.success(t("incomingRecord.UpdatedSuccessfully"));
invalidateConfig();
} catch (error) {
handleError(error);
toast.error(t("incomingRecord.FailedToUpdate"));
}
};
const handleCreateBankRecordToggleChange = async (checked: boolean) => {
if (isGlobal) return;
try {
if (configItem?.id) {
await updateConfiguration({
id: configItem.id,
payload: {
organizationId: configItem.organizationId,
positionTypeId: configItem.positionTypeId,
timeframe: configItem.timeframe,
canCreateBankRecord: checked,
},
});
} else {
await onToggle?.(row.id, checked, "canCreateBankRecord");
}
toast.success(t("incomingRecord.UpdatedSuccessfully"));
invalidateConfig();
} catch (error) {
handleError(error);
toast.error(t("incomingRecord.FailedToUpdate"));
}
};
const rowName = "name" in row ? row.name : { am: "", en: "" };
const isPositionType = "name" in row && "key" in row;
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{canBeModified && (
<DropdownMenuItem
onSelect={handleMigrate}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("common.Migrate")}</span>
</DropdownMenuItem>
)}
{canBeModified && isPositionType && (
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Edit")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleView}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.View")}</span>
</DropdownMenuItem>
{canBeModified && (
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Delete")}</span>
</DropdownMenuItem>
)}
{/* Toggle moved here from ToggleCell */}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanReceiveRecord")}
</span>
<Switch
checked={isCanReceiveRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanAssignRecord")}
</span>
<Switch
checked={isCanAssignRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleAssignToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between gap-4">
<span className="text-sm">
{t("contentManagement.CanCreateBankRecord")}
</span>
<Switch
checked={isCanCreateBankRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleCreateBankRecordToggleChange}
/>
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{showMigrateDialog && (
<PositionTypeMigrationModal
isOpen={showMigrateDialog}
onClose={() => {
setShowMigrateDialog(false);
}}
toId={row.id}
toName={localizedName(rowName)}
positionTypeOptions={positionTypeOptions}
/>
)}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="pb-4">
<DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
{isPositionType && showEditDialog && (
<CreatePositionForm
key={row.id + "-edit"}
mode="edit"
positionTypeId={row.id}
initialValues={{
nameAm: row.name.am,
nameEn: row.name.en,
unitId: row.unitId,
key: row.key,
}}
onSuccess={async () => {
setShowEditDialog(false);
if (onEdit) {
await onEdit();
}
}}
onCancel={() => setShowEditDialog(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("common.DeleteConfirmationMessage", {
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600">
{isDeleting ? t("common.Deleting") : t("common.Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PositionTypeActionsCell;

View File

@@ -0,0 +1,74 @@
import { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import { useLocalizedName } from "@/shared/common/localizedName";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import PositionTypeActionsCell from "./PositionTypeActions";
import { t } from "i18next";
interface PositionTypeResponse {
items: PositionTypeDto[];
count: number;
}
const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => {
const localizedName = useLocalizedName();
return <span>{localizedName(name)}</span>;
};
export const createPositionTypeColumns = (
_positionTypeResponse?: PositionTypeResponse,
globalPositionTypes?: PositionTypeResponse,
onDelete?: () => void | Promise<void>,
onEdit?: () => void | Promise<void>,
onToggle?: (
positionTypeId: string,
checked: boolean,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
) => void | Promise<void>,
isGlobal?: boolean,
): ColumnDef<PositionTypeDto>[] => [
{
accessorKey: "name",
header: () => t("common.name"),
cell: ({ row }) => <NameCell name={row.original.name} />,
},
{
accessorKey: "key",
header: () => t("contentManagement.key"),
cell: ({ row }) => <span>{row.original.key}</span>,
},
{
accessorKey: "createdAt",
header: () => t("contentManagement.createdAt"),
cell: ({ row }) => (
<span>
{format(new Date(row.original.createdAt), "MMM d, yyyy HH:mm")}
</span>
),
},
{
accessorKey: "updatedAt",
header: () => t("contentManagement.updatedAt"),
cell: ({ row }) => (
<span>
{format(new Date(row.original.updatedAt), "MMM d, yyyy HH:mm")}
</span>
),
},
{
id: "actions",
header: () => t("userRecord.Actions"),
cell: ({ row }) => (
<PositionTypeActionsCell
row={row.original}
globalPositionTypes={globalPositionTypes}
onDelete={onDelete}
onEdit={onEdit}
onToggle={onToggle}
isGlobal={isGlobal}
/>
),
},
];
export const PositionTypeColumnDefn = createPositionTypeColumns();

View File

@@ -0,0 +1,422 @@
import React, { useState, useMemo } from "react";
import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import useSettings from "@/record-management/components/hooks/useSettings";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTrigger,
} from "@/shared/common/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Search, X, ArrowLeftRight } from "lucide-react";
import { Badge } from "@/shared/common/ui/badge";
interface PositionTypeMigrationModalProps {
isOpen: boolean;
onClose: () => void;
fromId?: string; // Optional - can be selected if not provided
fromName?: string;
toId?: string; // Optional - can be selected if not provided
toName?: string;
unitId?: string;
positionTypeOptions?: { label: string; value: string }[]; // For dropdown selection
}
const PositionTypeMigrationModal: React.FC<PositionTypeMigrationModalProps> = ({
isOpen,
onClose,
fromId: initialFromId,
fromName: initialFromName,
toId: initialToId,
toName: initialToName,
unitId,
positionTypeOptions = [],
}) => {
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1);
const pageSize = 20;
// State for from/to selection
const [fromId, setFromId] = useState<string>(initialFromId || "");
const [toId, setToId] = useState<string>(initialToId || "");
const { updatePositionTypeFromTo, migratePositionsByPositions } =
usePositionTypes();
const { allScopedDepartments } = useSettings();
const localizedName = useLocalizedName();
const { handleError } = useErrorHandler(t);
// Combined options so the preset (initial) from/to are also selectable on either side.
const combinedOptions = useMemo(() => {
const merged = [...positionTypeOptions];
const ensure = (value?: string, label?: string) => {
if (!value) return;
if (!merged.some((opt) => opt.value === value)) {
merged.push({ value, label: label || value });
}
};
ensure(initialFromId, initialFromName);
ensure(initialToId, initialToName);
return merged;
}, [positionTypeOptions, initialFromId, initialFromName, initialToId, initialToName]);
// Get names from combined options if not provided
const fromName =
combinedOptions.find((opt) => opt.value === fromId)?.label ||
initialFromName ||
fromId;
const toName =
combinedOptions.find((opt) => opt.value === toId)?.label ||
initialToName ||
toId;
const handleSwap = () => {
setFromId(toId);
setToId(fromId);
};
// Map positions from allScopedDepartments
const positions = useMemo(() => {
return Array.isArray(allScopedDepartments)
? allScopedDepartments.map((pos) => ({
id: pos.id,
name: localizedName(pos.name) || "N/A",
}))
: [];
}, [allScopedDepartments, localizedName]);
// Filter positions based on search query
const filteredPositions = useMemo(() => {
if (!searchQuery.trim()) return positions;
return positions.filter((pos) =>
pos.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [positions, searchQuery]);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 5) {
if (page * pageSize < filteredPositions.length) {
setPage((prev) => prev + 1);
}
}
};
const handleSelectPosition = (positionId: string) => {
if (!selectedPositions.includes(positionId)) {
setSelectedPositions((prev) => [...prev, positionId]);
setSearchQuery(""); // Clear search after selection
setPage(1); // Reset page
}
};
const handleRemovePosition = (positionId: string) => {
setSelectedPositions((prev) => prev.filter((id) => id !== positionId));
};
const handleMigrateAll = () => {
if (!fromId || !toId) {
toast.error(t("migration.selectBothPositionTypes"));
return;
}
updatePositionTypeFromTo.mutate(
{ toId, fromId },
{
onSuccess: () => {
toast.success(t("migration.allPositionsMigratedSuccess"));
onClose();
},
onError: (error) => {
handleError(error);
},
}
);
};
const handleMigrateSelected = () => {
if (!fromId || !toId) {
toast.error(t("migration.selectBothPositionTypes"));
return;
}
if (selectedPositions.length === 0) {
toast.error(t("migration.selectAtLeastOnePosition"));
return;
}
migratePositionsByPositions.mutate(
{
id: toId,
data: { positionIds: selectedPositions },
},
{
onSuccess: () => {
toast.success(
t("migration.selectedPositionsMigratedSuccess", { count: selectedPositions.length })
);
onClose();
},
onError: (error) => {
handleError(error);
},
}
);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 dark:border dark:border-gray-700 rounded-lg shadow-lg w-[700px] max-h-[80vh] overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t("migration.title")}</h2>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* From / To Selection with Swap */}
{combinedOptions.length > 0 && (
<div className="mb-4 flex items-end gap-2">
<div className="flex-1">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t("migration.fromPositionType")}</Label>
<Select value={fromId} onValueChange={setFromId}>
<SelectTrigger className="w-full mt-1">
<SelectValue placeholder={t("migration.selectSourcePositionType")} />
</SelectTrigger>
<SelectContent>
{combinedOptions
.filter((opt) => opt.value !== toId)
.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
type="button"
onClick={handleSwap}
disabled={!fromId && !toId}
title={t("migration.swap") || "Swap"}
aria-label={t("migration.swap") || "Swap from and to"}
className="mb-0.5 p-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowLeftRight className="h-4 w-4 text-gray-600 dark:text-gray-300" />
</button>
<div className="flex-1">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t("migration.toPositionType")}</Label>
<Select value={toId} onValueChange={setToId}>
<SelectTrigger className="w-full mt-1">
<SelectValue placeholder={t("migration.selectTargetPositionType")} />
</SelectTrigger>
<SelectContent>
{combinedOptions
.filter((opt) => opt.value !== fromId)
.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Display selected from/to */}
{fromId && toId && (
<p className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 rounded border border-blue-200 dark:border-blue-800">
<span className="text-sm text-gray-600 dark:text-gray-300">{t("migration.migrationPath")}:</span>
<br />
<strong className="text-blue-700 dark:text-blue-400">{fromName}</strong>
<span className="mx-2"></span>
<strong className="text-primary-700 dark:text-primary-400">{toName}</strong>
</p>
)}
{/* Option 1: Migrate all */}
<div className="mb-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="w-full bg-primary-600 text-white px-4 py-2 rounded hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
disabled={updatePositionTypeFromTo.isPending || !fromId || !toId}
>
{t("migration.migrateAllPositions")}
</button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-white dark:bg-gray-800 dark:border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-gray-900 dark:text-white">{t("migration.confirmMigration")}</AlertDialogTitle>
<AlertDialogDescription className="text-gray-600 dark:text-gray-300">
{t("migration.migrateAllDescription", { fromName, toName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-white dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600">{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleMigrateAll}
className="bg-primary-600 hover:bg-primary-700 text-white"
>
{t("migration.yesMigrateAll")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Option 2: Migrate selected */}
<div>
<Label className="mb-2 font-medium text-gray-700 dark:text-gray-300">{t("migration.migrateSelectedPositions")}</Label>
{/* Selected Positions Display */}
{selectedPositions.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3 p-2 border rounded bg-gray-50 dark:bg-gray-900 dark:border-gray-700">
{selectedPositions.map((posId) => {
const position = positions.find((p) => p.id === posId);
return (
<Badge
key={posId}
variant="secondary"
className="flex items-center gap-1 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
>
{position?.name || posId}
<X
className="h-3 w-3 cursor-pointer hover:text-red-600"
onClick={() => handleRemovePosition(posId)}
/>
</Badge>
);
})}
</div>
)}
{/* Position Select Dropdown with Search */}
<Select onValueChange={handleSelectPosition}>
<SelectTrigger>
<SelectValue placeholder={t("migration.selectPositionsToMigrate")} />
</SelectTrigger>
<SelectContent
style={{ maxHeight: "300px", overflowY: "auto" }}
onScroll={handleScroll}
>
{/* Search Input inside Dropdown */}
<div className="sticky top-0 z-10 bg-background p-2 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
type="text"
placeholder={t("migration.searchPositions")}
value={searchQuery}
onChange={(e) => {
e.stopPropagation();
setSearchQuery(e.target.value);
setPage(1);
}}
onClick={(e) => e.stopPropagation()}
className="pl-10 h-9 text-sm"
/>
</div>
</div>
{/* Position List */}
{filteredPositions.length > 0 ? (
filteredPositions.slice(0, page * pageSize).map((pos) => (
<SelectItem
key={pos.id}
value={pos.id}
disabled={selectedPositions.includes(pos.id)}
>
<div className="flex items-center justify-between w-full">
<span>{pos.name}</span>
{selectedPositions.includes(pos.id) && (
<span className="text-xs text-primary-600 ml-2">
{t("migration.selected")}
</span>
)}
</div>
</SelectItem>
))
) : (
<div className="text-center py-4 text-sm text-muted-foreground">
{searchQuery
? t("migration.noPositionsFound")
: t("migration.noPositionsAvailable")}
</div>
)}
</SelectContent>
</Select>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="w-full mt-3 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
disabled={
migratePositionsByPositions.isPending ||
selectedPositions.length === 0 ||
!fromId ||
!toId
}
>
{t("migration.migrateSelectedButton", { count: selectedPositions.length })}
</button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-white dark:bg-gray-800 dark:border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-gray-900 dark:text-white">{t("migration.confirmSelectedMigration")}</AlertDialogTitle>
<AlertDialogDescription className="text-gray-600 dark:text-gray-300">
{t("migration.migrateSelectedDescription", { count: selectedPositions.length, fromName, toName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-white dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600">{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleMigrateSelected}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{t("common.Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
};
export default PositionTypeMigrationModal;

View File

@@ -0,0 +1,40 @@
import { Check, X } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import type { EmployeePositionChangeRequest } from "@/user-management/services/api/employeePositionChangeRequestService";
export type UserPositionApprovalDecision = "approve" | "reject";
interface UserPositionApprovalActionsProps {
item: EmployeePositionChangeRequest;
onDecision: (
type: UserPositionApprovalDecision,
item: EmployeePositionChangeRequest,
) => void;
}
export default function UserPositionApprovalActions({
item,
onDecision,
}: UserPositionApprovalActionsProps) {
return (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => onDecision("approve", item)}
className="gap-1.5"
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDecision("reject", item)}
className="gap-1.5"
>
<X className="h-4 w-4" />
Reject
</Button>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { EmployeePositionChangeRequest } from "@/user-management/services/api/employeePositionChangeRequestService";
import UserPositionApprovalActions, {
type UserPositionApprovalDecision,
} from "./UserPositionApprovalActions";
import { getDisplayName } from "./userPositionApprovalUtils";
interface UserPositionApprovalCardsProps {
items: EmployeePositionChangeRequest[];
onDecision: (
type: UserPositionApprovalDecision,
item: EmployeePositionChangeRequest,
) => void;
}
export default function UserPositionApprovalCards({
items,
onDecision,
}: UserPositionApprovalCardsProps) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<article
key={item.id}
className="rounded-lg border bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900"
>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">
{getDisplayName(item.employee?.name)}
</h2>
<p className="text-sm text-slate-500">
{item.employee?.user?.email || "-"}
</p>
<dl className="my-4 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-slate-500">From</dt>
<dd>{getDisplayName(item.fromPosition?.name)}</dd>
<dt className="text-slate-500">To</dt>
<dd>{getDisplayName(item.toPosition?.name)}</dd>
<dt className="text-slate-500">Status</dt>
<dd>{item.status || "PENDING"}</dd>
</dl>
<UserPositionApprovalActions item={item} onDecision={onDecision} />
</article>
))}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { ColumnDef } from "@tanstack/react-table";
import type { EmployeePositionChangeRequest } from "@/user-management/services/api/employeePositionChangeRequestService";
import UserPositionApprovalActions, {
type UserPositionApprovalDecision,
} from "./UserPositionApprovalActions";
import { getDisplayName } from "./userPositionApprovalUtils";
import { renderStatus } from "@/record-management/utils/renderDetails";
export const createUserPositionApprovalColumns = (
onDecision: (
type: UserPositionApprovalDecision,
item: EmployeePositionChangeRequest,
) => void,
): ColumnDef<EmployeePositionChangeRequest>[] => [
{
accessorKey: "employee",
header: () => "User",
cell: ({ row }) => (
<span className="font-medium">
{getDisplayName(row.original.employee?.name)}
</span>
),
},
{
id: "email",
header: () => "Email",
cell: ({ row }) => (
<span className="text-slate-500">
{row.original.employee?.user?.email || "-"}
</span>
),
},
{
id: "fromPosition",
header: () => "From Position",
cell: ({ row }) => (
<span>{getDisplayName(row.original.fromPosition?.name)}</span>
),
},
{
id: "toPosition",
header: () => "To Position",
cell: ({ row }) => (
<span>{getDisplayName(row.original.toPosition?.name)}</span>
),
},
{
accessorKey: "status",
header: () => "Status",
cell: ({ row }) => (
<span>
{renderStatus(
row.original.status || "pending",
row.original.status || "pending",
)}
</span>
),
},
{
id: "actions",
header: () => "Actions",
cell: ({ row }) => (
<UserPositionApprovalActions
item={row.original}
onDecision={onDecision}
/>
),
},
];

View File

@@ -0,0 +1,82 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Textarea } from "@/shared/common/ui/textarea";
import type { EmployeePositionChangeRequest } from "@/user-management/services/api/employeePositionChangeRequestService";
import type { UserPositionApprovalDecision } from "./UserPositionApprovalActions";
interface UserPositionApprovalDecisionDialogProps {
decision: {
type: UserPositionApprovalDecision;
item: EmployeePositionChangeRequest;
} | null;
comment: string;
isSubmitting: boolean;
onCommentChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export default function UserPositionApprovalDecisionDialog({
decision,
comment,
isSubmitting,
onCommentChange,
onClose,
onSubmit,
}: UserPositionApprovalDecisionDialogProps) {
return (
<Dialog
open={Boolean(decision)}
onOpenChange={(open) => !open && !isSubmitting && onClose()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{decision?.type === "approve" ? "Approve" : "Reject"} position
change request
</DialogTitle>
<DialogDescription>
{decision?.type === "approve"
? "Confirm this employee position change request."
: "Confirm that this employee position change request should be rejected."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="decision-comment" className="text-sm font-medium">
Comment
</label>
<Textarea
id="decision-comment"
value={comment}
onChange={(event) => onCommentChange(event.target.value)}
placeholder="Add a comment for this decision"
rows={4}
/>
</div>
<DialogFooter>
<Button variant="outline" disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button
variant={decision?.type === "reject" ? "destructive" : "default"}
disabled={isSubmitting}
onClick={onSubmit}
>
{isSubmitting
? "Submitting..."
: decision?.type === "approve"
? "Approve"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,191 @@
import { useMemo, useState } from "react";
import { Grid2X2, List, RefreshCw, Search, UserRoundCheck } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useEmployeePositionChangeRequests } from "@/user-management/hooks/useEmployeePositionChangeRequests";
import type { EmployeePositionChangeRequest } from "@/user-management/services/api/employeePositionChangeRequestService";
import UserPositionApprovalCards from "./UserPositionApprovalCards";
import { createUserPositionApprovalColumns } from "./UserPositionApprovalColumns";
import UserPositionApprovalDecisionDialog from "./UserPositionApprovalDecisionDialog";
import type { UserPositionApprovalDecision } from "./UserPositionApprovalActions";
import { getDisplayName } from "./userPositionApprovalUtils";
type ViewMode = "table" | "card";
const PAGE_SIZE = 10;
export default function UserPositionApprovalList() {
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [search, setSearch] = useState("");
const [pageIndex, setPageIndex] = useState(0);
const [decision, setDecision] = useState<{
type: UserPositionApprovalDecision;
item: EmployeePositionChangeRequest;
} | null>(null);
const [comment, setComment] = useState("");
const { requestsQuery, approve, reject, isApproving, isRejecting } =
useEmployeePositionChangeRequests({ take: 50, skip: 0, status: "pending" });
const items = useMemo(() => {
const term = search.trim().toLowerCase();
const source = requestsQuery.data?.items ?? [];
if (!term) return source;
return source.filter((item) =>
[
getDisplayName(item.employee?.name),
item.employee?.user?.email,
getDisplayName(item.fromPosition?.name),
getDisplayName(item.toPosition?.name),
item.status,
].some((value) => value?.toLowerCase().includes(term)),
);
}, [requestsQuery.data?.items, search]);
const isSubmitting = isApproving || isRejecting;
const paginatedItems = useMemo(() => {
const start = pageIndex * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}, [items, pageIndex]);
const openDecision = (
type: UserPositionApprovalDecision,
item: EmployeePositionChangeRequest,
) => {
setComment("");
setDecision({ type, item });
};
const closeDecision = () => {
setDecision(null);
setComment("");
};
const submitDecision = async () => {
if (!decision) return;
const payload = { id: decision.item.id, comment: comment.trim() };
if (decision.type === "approve") {
await approve(payload);
} else {
await reject(payload);
}
closeDecision();
};
const columns = useMemo(
() => createUserPositionApprovalColumns(openDecision),
[],
);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
return (
<div className="w-full space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-slate-900 dark:text-slate-100">
<UserRoundCheck className="h-6 w-6 text-primary" />
User Position Approvals
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Review pending employee position change requests.
</p>
</div>
<span className="w-fit rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-800 dark:bg-amber-950 dark:text-amber-200">
{requestsQuery.data?.count ?? 0} pending
</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPageIndex(0);
}}
placeholder="Search by user, email, position, or status"
className="h-10 w-full rounded-md border border-slate-200 bg-white pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-primary/30 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="flex rounded-md border border-slate-200 p-1 dark:border-slate-700">
<Button
size="sm"
variant={viewMode === "table" ? "default" : "ghost"}
onClick={() => setViewMode("table")}
aria-label="Table view"
>
<List className="h-4 w-4" />
</Button>
<Button
size="sm"
variant={viewMode === "card" ? "default" : "ghost"}
onClick={() => setViewMode("card")}
aria-label="Card view"
>
<Grid2X2 className="h-4 w-4" />
</Button>
</div>
</div>
{requestsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center dark:border-red-900 dark:bg-red-950/30">
<p className="text-sm text-red-700 dark:text-red-300">
The employee position change request API is not available.
</p>
<Button
variant="outline"
className="mt-4 gap-2"
onClick={() => requestsQuery.refetch()}
>
<RefreshCw className="h-4 w-4" /> Retry
</Button>
</div>
) : viewMode === "card" ? (
requestsQuery.isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className="h-44 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800"
/>
))}
</div>
) : items.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center text-slate-500 dark:border-slate-700">
<UserRoundCheck className="mx-auto mb-3 h-9 w-9" />
<p className="font-medium">No pending user position approvals</p>
</div>
) : (
<UserPositionApprovalCards items={items} onDecision={openDecision} />
)
) : (
<AdvancedTable
columns={columns}
data={paginatedItems}
tableName="User Position Approvals"
itemCount={items.length}
pageIndex={pageIndex}
pageSize={PAGE_SIZE}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
refresh={() => requestsQuery.refetch()}
isLoading={requestsQuery.isLoading}
hideToolbarFilter
/>
)}
<UserPositionApprovalDecisionDialog
decision={decision}
comment={comment}
isSubmitting={isSubmitting}
onCommentChange={setComment}
onClose={closeDecision}
onSubmit={submitDecision}
/>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export const getDisplayName = (
value?: string | { en?: string; am?: string },
): string => {
if (!value) return "-";
return typeof value === "string" ? value : value.en || value.am || "-";
};

View File

@@ -0,0 +1,17 @@
export interface ApplicationName {
am: string;
en: string;
}
export interface ApplicationDto {
id: string;
key: string;
name: ApplicationName;
createdAt: string;
updatedAt: string;
}
export interface ApplicationListResponse {
count: number;
items: ApplicationDto[];
}

View File

@@ -0,0 +1,105 @@
export interface EmployeeWithUnitDto {
avatar: string | undefined;
id: string;
name: {
charAt(
arg0: number
): import("react").ReactNode | Iterable<import("react").ReactNode>;
am: string;
en: string;
};
user: {
id: string;
name: {
am: string;
en: string;
};
email: string;
phoneNumber?: string;
};
employeePositions: {
id: string;
employeeId: string;
position: {
id: string;
name: {
am: string;
en: string;
};
};
}[];
}
export interface EmployeeWithUnitListResponse {
count: number;
items: EmployeeWithUnitDto[];
}
export interface EmployeeByPositionDto {
employeePositions: any;
id: string;
isCurrent: boolean;
status: string;
name: {
am: string;
en: string;
};
organizationId: string;
user: {
id: string;
name: {
am: string;
en: string;
};
username: string;
email: string;
userType: string;
sharepointId: string | null;
status: string;
};
}
export interface EmployeePosition {
id: string;
position: {
id: string;
name: {
am: string;
en: string;
};
};
}
export interface Employee {
id: string;
name: {
am: string;
en: string;
};
employeePositions: EmployeePosition[];
}
export interface User {
id: string;
name: {
am: string;
en: string;
};
email: string;
employee: Employee[];
}
export interface UserDTO {
id: string;
name: {
am: string;
en: string;
};
email: string;
employee: Employee[];
}
export interface UserWithUnitListResponse {
count: number;
items: UserDTO[];
}

View File

@@ -0,0 +1,17 @@
export interface PermissionName {
am: string;
en: string;
}
export interface PermissionDto {
id: string;
key: string;
name: PermissionName;
createdAt: string;
updatedAt: string;
}
export interface PermissionListResponse {
count: number;
items: PermissionDto[];
}

View File

@@ -0,0 +1,16 @@
export interface PositionDto {
createdAt: string;
updatedAt: string;
id: string;
name: {
am: string;
en: string;
};
key: string;
parentPositionId: string | null;
unitId: string;
organizationId: string;
projectId: string | null;
subPositions: PositionDto[];
positionTypeId: string;
}

View File

@@ -0,0 +1,19 @@
export interface PositionTypeDto {
id: string;
name: {
am: string;
en: string;
};
key: string;
unitId: string;
canReceiveRecord: boolean;
canCreateBankRecord?: boolean;
canAssignRecord: boolean;
createdAt: string;
updatedAt: string;
}
export interface PositionTypesListResponse {
count: number;
items: PositionTypeDto[];
}

View File

@@ -0,0 +1,27 @@
export interface CreateRecordTagPayload {
name: {
am: string;
en: string;
};
key: string;
unitId: string;
}
export interface UpdateRecordTagPayload
extends Partial<CreateRecordTagPayload> {}
export interface RecordTag {
createdAt: string;
updatedAt: string;
id: string;
name: {
am: string;
en: string;
};
key: string;
unitId: string;
}
export interface RecordTagResponse {
items: RecordTag[];
count: number;
}

View File

@@ -0,0 +1,32 @@
export interface TeamMemberDto {
status: string;
organizationId: string;
id: string;
isCurrent: boolean;
name: string | null;
employeePositions?: Array<{
id: string;
employeeId: string;
position: {
id: string;
name: {
am: string;
en: string;
};
};
}>;
user: {
id: string;
name: {
am: string;
en: string;
};
username: string;
email: string;
userType: "employee" | string;
sharepointId: string | null;
status: "accepted" | string;
phoneNumber: string;
hasSetPassword: boolean
};
}

View File

@@ -0,0 +1,14 @@
export interface UnitDto {
id: string;
name: UnitName;
key: string;
organizationId: string;
parentUnitId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UnitName {
am: string;
en: string;
}

View File

@@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
applicationService,
CreateApplicationDto,
UpdateApplicationDto,
} from "@/user-management/services/api/applicationService";
import {
ApplicationDto,
ApplicationListResponse,
} from "@/user-management/dto/applications/applicationDto";
export const useApplications = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const { data, isLoading, isError, refetch } =
useQuery<ApplicationListResponse>({
queryKey: ["applications"],
queryFn: () => applicationService.getAll().then((res) => res.data),
staleTime: 1000 * 60 * 5,
});
const createApplication = useMutation({
mutationFn: (payload: CreateApplicationDto) =>
applicationService.create(payload),
onSuccess: () => {
toast.success("Application created");
queryClient.invalidateQueries({ queryKey: ["applications"] });
},
onError: (error) => {
handleError(error);
},
});
const updateApplication = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateApplicationDto }) =>
applicationService.update(id, data),
onSuccess: () => {
toast.success("Application updated");
queryClient.invalidateQueries({ queryKey: ["applications"] });
},
onError: (error) => {
handleError(error);
},
});
const deleteApplication = useMutation({
mutationFn: (id: string) => applicationService.delete(id),
onSuccess: () => {
toast.success("Application deleted");
queryClient.invalidateQueries({ queryKey: ["applications"] });
},
onError: (error) => {
handleError(error);
},
});
return {
applications: data?.items ?? [],
applicationsResponse: data,
isLoading,
isError,
refetch,
createApplication,
updateApplication,
deleteApplication,
};
};

View File

@@ -0,0 +1,155 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
getArchivedUnits,
restoreUnit,
softDeleteUnit,
} from "@/user-management/services/api/unitService";
import {
getArchivedPositions,
restorePosition,
softDeletePosition,
} from "@/user-management/services/api/positionService";
import {
getArchivedOrganizations,
restoreOrganization,
softDeleteOrganization,
} from "@/shared/services/organizationsService";
export const useArchivedUnits = (parentId: string | undefined) => {
return useQuery({
queryKey: ["archived-units", parentId],
queryFn: async () => {
if (!parentId) return null;
const { data } = await getArchivedUnits(parentId);
return data;
},
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
};
export const useArchivedOrganizations = () => {
return useQuery({
queryKey: ["archived-organizations"],
queryFn: async () => {
const { data } = await getArchivedOrganizations();
return data;
},
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
};
export const useArchivedPositions = (parentId: string | undefined) => {
return useQuery({
queryKey: ["archived-positions", parentId],
queryFn: async () => {
if (!parentId) return null;
const { data } = await getArchivedPositions(parentId);
return data;
},
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
};
export const useArchiveActions = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const invalidateArchivedUnits = () => {
queryClient.invalidateQueries({ queryKey: ["archived-units"] });
queryClient.invalidateQueries({ queryKey: ["unitList"] });
queryClient.invalidateQueries({ queryKey: ["unitChildren"] });
};
const invalidateArchivedPositions = () => {
queryClient.invalidateQueries({ queryKey: ["archived-positions"] });
queryClient.invalidateQueries({ queryKey: ["positions"] });
};
const invalidateArchivedOrganizations = () => {
queryClient.invalidateQueries({ queryKey: ["archived-organizations"] });
queryClient.invalidateQueries({ queryKey: ["organizations"] });
queryClient.invalidateQueries({ queryKey: ["organizationsResponse"] });
};
const softDeleteUnitMutation = useMutation({
mutationFn: (id: string) => softDeleteUnit(id),
onSuccess: () => {
toast.success(t("archive.unitArchived", "Unit archived"));
invalidateArchivedUnits();
},
onError: handleError,
});
const restoreUnitMutation = useMutation({
mutationFn: (id: string) => restoreUnit(id),
onSuccess: () => {
toast.success(t("archive.unitRestored", "Unit restored"));
invalidateArchivedUnits();
},
onError: handleError,
});
const softDeletePositionMutation = useMutation({
mutationFn: (id: string) => softDeletePosition(id),
onSuccess: () => {
toast.success(t("archive.positionArchived", "Position archived"));
invalidateArchivedPositions();
},
onError: handleError,
});
const restorePositionMutation = useMutation({
mutationFn: (id: string) => restorePosition(id),
onSuccess: () => {
toast.success(t("archive.positionRestored", "Position restored"));
invalidateArchivedPositions();
},
onError: handleError,
});
const softDeleteOrganizationMutation = useMutation({
mutationFn: (id: string) => softDeleteOrganization(id),
onSuccess: () => {
toast.success(t("archive.organizationArchived", "Organization archived"));
invalidateArchivedOrganizations();
},
onError: handleError,
});
const restoreOrganizationMutation = useMutation({
mutationFn: (id: string) => restoreOrganization(id),
onSuccess: () => {
toast.success(
t("archive.organizationRestored", "Organization restored"),
);
invalidateArchivedOrganizations();
},
onError: handleError,
});
return {
softDeleteUnit: softDeleteUnitMutation.mutate,
isArchivingUnit: softDeleteUnitMutation.isPending,
restoreUnit: restoreUnitMutation.mutate,
isRestoringUnit: restoreUnitMutation.isPending,
softDeletePosition: softDeletePositionMutation.mutate,
isArchivingPosition: softDeletePositionMutation.isPending,
restorePosition: restorePositionMutation.mutate,
isRestoringPosition: restorePositionMutation.isPending,
softDeleteOrganization: softDeleteOrganizationMutation.mutate,
isArchivingOrganization: softDeleteOrganizationMutation.isPending,
restoreOrganization: restoreOrganizationMutation.mutate,
isRestoringOrganization: restoreOrganizationMutation.isPending,
};
};

View File

@@ -0,0 +1,103 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
employeePositionChangeRequestService,
type BulkApproveEmployeePositionChangePayload,
type EmployeePositionChangeDecisionPayload,
type EmployeePositionChangeRequestParams,
type TransferEmployeePositionPayload,
} from "@/user-management/services/api/employeePositionChangeRequestService";
export const EMPLOYEE_POSITION_CHANGE_REQUESTS_QUERY_KEY =
"employee-position-change-requests";
interface DecisionVariables extends EmployeePositionChangeDecisionPayload {
id: string;
}
export const useEmployeePositionChangeRequests = (
params: EmployeePositionChangeRequestParams = {},
) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const requestsQuery = useQuery({
queryKey: [EMPLOYEE_POSITION_CHANGE_REQUESTS_QUERY_KEY, params],
queryFn: () => employeePositionChangeRequestService.list(params),
});
const getRequestByIdQuery = (id?: string | null) =>
useQuery({
queryKey: [EMPLOYEE_POSITION_CHANGE_REQUESTS_QUERY_KEY, id],
queryFn: () => employeePositionChangeRequestService.getById(id!),
enabled: !!id,
});
const invalidateRequests = () =>
queryClient.invalidateQueries({
queryKey: [EMPLOYEE_POSITION_CHANGE_REQUESTS_QUERY_KEY],
});
const transferMutation = useMutation({
mutationFn: (payload: TransferEmployeePositionPayload) =>
employeePositionChangeRequestService.transfer(payload),
onSuccess: () => {
invalidateRequests();
},
onError: (error) => {
handleError(error);
},
});
const bulkApproveMutation = useMutation({
mutationFn: (payload: BulkApproveEmployeePositionChangePayload) =>
employeePositionChangeRequestService.bulkApprove(payload),
onSuccess: () => {
toast.success("Employee position change requests approved successfully");
invalidateRequests();
},
onError: (error) => {
handleError(error);
},
});
const approveMutation = useMutation({
mutationFn: ({ id, comment }: DecisionVariables) =>
employeePositionChangeRequestService.approve(id, { comment }),
onSuccess: () => {
toast.success("Employee position change request approved successfully");
invalidateRequests();
},
onError: (error) => {
handleError(error);
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, comment }: DecisionVariables) =>
employeePositionChangeRequestService.reject(id, { comment }),
onSuccess: () => {
toast.success("Employee position change request rejected successfully");
invalidateRequests();
},
onError: (error) => {
handleError(error);
},
});
return {
requestsQuery,
getRequestByIdQuery,
transfer: transferMutation.mutateAsync,
isTransferring: transferMutation.isPending,
bulkApprove: bulkApproveMutation.mutateAsync,
isBulkApproving: bulkApproveMutation.isPending,
approve: approveMutation.mutateAsync,
isApproving: approveMutation.isPending,
reject: rejectMutation.mutateAsync,
isRejecting: rejectMutation.isPending,
};
};

View File

@@ -0,0 +1,185 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
activateUser,
AssignEmployeePayload,
assignEmployees,
assignFirstsForSecond,
assignSecondsForFirst,
deactivateUser,
deleteEmployeePosition,
EmployeePositionQueryParams,
getGivenFirst,
getGivenSecond,
InactiveEPPayload,
inviteEmployeePosition,
removeFirstsForSecond,
removeSecondsForFirst,
setInactive,
} from "../services/api/employeePositionsService";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const useEmployeePositions = (params?: EmployeePositionQueryParams) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// --- Queries ---
const getGivenFirstQuery = (id: string) =>
useQuery({
queryKey: ["positionEmployees", "given-first", id],
queryFn: () => getGivenFirst(id, params).then((res) => res.data),
enabled: !!id,
});
const getGivenSecondQuery = (id: string) =>
useQuery({
queryKey: ["positionEmployees", "given-second", id],
queryFn: () => getGivenSecond(id, params).then((res: any) => res.data),
enabled: !!id,
});
// --- Mutations ---
const invite = useMutation({
mutationFn: ({
payload,
successCallback,
}: {
payload: any;
successCallback: () => void;
}) => inviteEmployeePosition(payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] });
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
const deactivate = useMutation({
mutationFn: ({
payload,
successCallback,
}: {
payload: string;
successCallback: () => void;
}) => deactivateUser(payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["deleteEmployees"] });
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] });
queryClient.invalidateQueries({ queryKey: ["employees"] });
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
const activate = useMutation({
mutationFn: ({
payload,
successCallback,
}: {
payload: string;
successCallback: () => void;
}) => activateUser(payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["archived-users"] });
queryClient.invalidateQueries({ queryKey: ["employees"] });
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
const setInactiveMutation = useMutation({
mutationFn: ({
payload,
successCallback,
}: {
payload: InactiveEPPayload;
successCallback: () => void;
}) => setInactive(payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] });
toast.success("Team member removed successfully.");
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
const assignFirstsForSecondMutation = useMutation({
mutationFn: (payload: any) => assignFirstsForSecond(payload),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] }),
});
const assignSecondsForFirstMutation = useMutation({
mutationFn: (payload: any) => assignSecondsForFirst(payload),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] }),
});
const assignEmployeeForPosition = useMutation({
mutationFn: (payload: AssignEmployeePayload) => assignEmployees(payload),
onSuccess: () =>{
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] });
queryClient.invalidateQueries({ queryKey: ["employees"] });
},
onError: (error) => {
handleError(error);
},
});
const removeFirstsForSecondMutation = useMutation({
mutationFn: (payload: any) => removeFirstsForSecond(payload),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] }),
});
const removeSecondsForFirstMutation = useMutation({
mutationFn: (payload: any) => removeSecondsForFirst(payload),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] }),
});
const remove = useMutation({
mutationFn: (id: string) => deleteEmployeePosition(id),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["positionEmployees"] }),
});
// --- Return exposed mutateAsync and query helpers ---
return {
getGivenFirstQuery,
getGivenSecondQuery,
invite: invite.mutateAsync,
isInviting: invite.isPending,
setInactive: setInactiveMutation.mutateAsync,
isDeActivatingUser: setInactiveMutation.isPending,
assignFirstsForSecond: assignFirstsForSecondMutation.mutateAsync,
assignSecondsForFirst: assignSecondsForFirstMutation.mutateAsync,
removeFirstsForSecond: removeFirstsForSecondMutation.mutateAsync,
assignEmployee: assignEmployeeForPosition.mutateAsync,
isAssigning: assignEmployeeForPosition.isPending,
removeSecondsForFirst: removeSecondsForFirstMutation.mutateAsync,
remove: remove.mutateAsync,
deactivateUser: deactivate.mutateAsync,
isDeactivating: deactivate.isPending,
activateUser: activate.mutateAsync,
isActivatingUser: activate.isPending,
};
};

View File

@@ -0,0 +1,98 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { EmployeeWithUnitListResponse, UserWithUnitListResponse } from "../dto/employees/employees";
import { EmployeeQueryParams, employeeService } from "../services/api/employeesService";
import { getEmployeesUnderOrg } from "@/shared/services/organizationsService";
export const useEmployees = ({
unitId,
organizationId,
params,
}: {
unitId?: string;
organizationId?: string;
params?:EmployeeQueryParams;
}) => {
const {
data: employeesResponse,
isLoading,
isError,
refetch,
} = useQuery<UserWithUnitListResponse>({
queryKey: ["employees", unitId],
queryFn: async () => {
const { data } = await employeeService.getUsersWithUnitById(unitId!, {
take: 3000,
});
return data;
},
enabled: !!unitId,
staleTime: 5 * 60 * 1000,
retry: false,
});
const {
data: employeesResponseByOrg,
isLoading: isLoadingEmployeesByOrg,
isError: isErrorEmployeesByOrg,
refetch: refetchEmployeesByOrg,
} = useQuery<EmployeeWithUnitListResponse | UserWithUnitListResponse>({
queryKey: ["employees", organizationId, unitId, params],
queryFn: async () => {
// If unitId is selected, use the active-with-unit endpoint
if (unitId) {
const { data } = await employeeService.getUsersWithUnitById(unitId, params || {});
return data;
}
// Otherwise use the by-organization endpoint
const { data } = await getEmployeesUnderOrg(
organizationId!,
params
);
return data;
},
enabled: !!(organizationId || unitId),
staleTime: 5 * 60 * 1000,
retry: false,
});
const { mutate: getEmployeeDetails, isPending: isFetchingEmployee } =
useMutation({
mutationFn: async (id: string) => {
const { data } = await employeeService.getEmployee(id);
return data;
},
});
// Normalize the response to always have a consistent structure
const normalizedEmployeesResponseByOrg = employeesResponseByOrg
? {
count: employeesResponseByOrg.count,
items: employeesResponseByOrg.items.map((item: any) => {
// If the item already has a user property, it's EmployeeWithUnitDto
if ('user' in item && item.user) {
return item;
}
// If not, wrap it as UserDTO in the user property for consistency
return {
id: item.id,
user: item,
employeePositions: item.employeePositions || [],
};
}),
}
: undefined;
return {
employeesResponse,
isLoading,
isError,
refetch,
getEmployeeByDetails: getEmployeeDetails,
isFetchingEmployee,
refetchEmployeesByOrg,
isErrorEmployeesByOrg,
isLoadingEmployeesByOrg,
employeesResponseByOrg: normalizedEmployeesResponseByOrg,
};
};

View File

@@ -0,0 +1,138 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getTemplatesByUnitId,
getTemplateById,
createTemplate,
updateTemplate,
deleteTemplate,
LetterTemplatePayload,
LetterTemplate,
} from "@/user-management/services/api/letterTemplateService";
export interface UseLetterTemplatesOptions {
skip?: number;
take?: number;
// BE expects "field:DIRECTION" form, e.g. "createdAt:DESC".
orderBy?: string;
order?: string;
enabled?: boolean;
}
export const useLetterTemplates = (
unitId: string,
options: UseLetterTemplatesOptions = {},
) => {
const queryClient = useQueryClient();
const {
skip = 0,
take = 20,
// Default to newest-first so freshly-created templates appear at the top.
orderBy = "createdAt:DESC",
order,
enabled = true,
} = options;
const params = {
skip,
take,
orderBy,
...(order ? { order } : {}),
};
// GET all templates by unitId
const {
data: letterTemplatesResponse,
isLoading,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: ["letter-templates", unitId, params],
queryFn: async () => {
const { data } = await getTemplatesByUnitId(unitId, params);
return data;
},
enabled: !!unitId && enabled,
staleTime: 5 * 60 * 1000,
retry: false,
placeholderData: keepPreviousData,
});
// GET single template
const { mutate: getTemplateByDetails, isPending: isFetchingTemplate } =
useMutation({
mutationFn: async (id: string) => {
const { data } = await getTemplateById(id);
return data as LetterTemplate;
},
});
// CREATE
const { mutate: createLetterTemplate, isPending: isCreating } = useMutation({
mutationFn: async (data: LetterTemplatePayload) => {
const { data: created } = await createTemplate(data);
return created;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["letter-templates", variables.unitId],
});
},
});
// UPDATE
const { mutate: updateLetterTemplate, isPending: isUpdating } = useMutation({
mutationFn: async (payload: {
id: string;
data: LetterTemplatePayload;
}) => {
const { data: updated } = await updateTemplate(payload.id, payload.data);
return updated;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["letter-templates", variables.data.unitId],
});
queryClient.invalidateQueries({
queryKey: ["letter-template", variables.id],
});
},
});
// DELETE
const { mutate: deleteLetterTemplate, isPending: isDeleting } = useMutation({
mutationFn: async ({ id }: { id: string }) => {
await deleteTemplate(id);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["letter-templates", unitId],
});
},
});
return {
letterTemplatesResponse,
items: letterTemplatesResponse?.items ?? [],
count: letterTemplatesResponse?.count ?? 0,
isLoading,
isFetching,
isError,
refetch,
getTemplateByDetails,
isFetchingTemplate,
createLetterTemplate,
isCreating,
updateLetterTemplate,
isUpdating,
deleteLetterTemplate,
isDeleting,
};
};

Some files were not shown because too many files have changed in this diff Show More