moh/admin-portfolio-ux-improvements
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-15 04:42:09 +01:00
parent 364f761420
commit 3eb9238268
7 changed files with 1352 additions and 482 deletions
+162 -114
View File
@@ -8,15 +8,14 @@ import {
Hash, Hash,
Layers3, Layers3,
Pencil, Pencil,
Sparkles, ShieldCheck,
ShieldOff,
Text, Text,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/_admin/portfolio/actions"; import type { deleteCategoryAction, upsertCategoryAction } from "@/app/_admin/portfolio/actions";
import { StatsCard } from "@/components/dashboard/stats-card";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
@@ -57,6 +56,10 @@ const copy = {
empty: "No categories yet.", empty: "No categories yet.",
deleteBlocked: "Delete becomes available only when no projects are assigned.", deleteBlocked: "Delete becomes available only when no projects are assigned.",
editCategory: "Edit Category", editCategory: "Edit Category",
visibilityActive: "Visible on public portfolio filters.",
visibilityInactive: "Hidden from public portfolio filters.",
statusSection: "Visibility & Status",
statusHint: "Control whether the category can be assigned and shown publicly.",
}; };
type CategoryAction = typeof upsertCategoryAction; type CategoryAction = typeof upsertCategoryAction;
@@ -145,6 +148,56 @@ function CategoryLocaleFields({
); );
} }
function CategoryStatusFields({
formId,
values,
}: {
formId: string;
values?: CategoryFormValues;
}) {
const isActive = values?.isActive ?? true;
return (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{copy.statusSection}</p>
<p className="text-sm text-muted-foreground">{copy.statusHint}</p>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="flex items-start gap-3">
{isActive ? (
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
) : (
<ShieldOff className="mt-0.5 h-4 w-4 text-status-warning" />
)}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-foreground">{copy.active}</p>
<Badge variant={isActive ? "success" : "warning"}>
{isActive ? "Public" : "Hidden"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{isActive ? copy.visibilityActive : copy.visibilityInactive}
</p>
</div>
</div>
</AppCard>
<label
htmlFor={`${formId}-isActive`}
className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm"
>
<Checkbox id={`${formId}-isActive`} name="isActive" defaultChecked={isActive} />
{copy.active}
</label>
</div>
</div>
);
}
function CategoryForm({ function CategoryForm({
formId, formId,
action, action,
@@ -200,17 +253,16 @@ function CategoryForm({
/> />
</div> </div>
</div> </div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={values?.isActive ?? true} />
{copy.active}
</label>
</div> </div>
</div> </div>
<Separator /> <Separator />
<CategoryLocaleFields idPrefix={formId} values={values} /> <CategoryLocaleFields idPrefix={formId} values={values} />
<Separator />
<CategoryStatusFields formId={formId} values={values} />
</form> </form>
); );
} }
@@ -232,7 +284,7 @@ function EditCategoryDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{copy.editCategory}</DialogTitle> <DialogTitle>{copy.editCategory}</DialogTitle>
<DialogDescription>{copy.editDescription}</DialogDescription> <DialogDescription>{copy.editDescription}</DialogDescription>
@@ -247,7 +299,7 @@ function EditCategoryDialog({
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p> <p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
<div className="mt-2"> <div className="mt-2">
<Badge variant={category.isActive ? "success" : "warning"}> <Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Active" : "Inactive"} {category.isActive ? "Public" : "Hidden"}
</Badge> </Badge>
</div> </div>
</AppCard> </AppCard>
@@ -276,7 +328,9 @@ function EditCategoryDialog({
<DialogFooter className="items-center justify-between sm:flex-row"> <DialogFooter className="items-center justify-between sm:flex-row">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."} {category.projectCount > 0
? `Delete blocked. ${copy.deleteBlocked}`
: "Category can be deleted."}
</p> </p>
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<form action={removeCategoryAction}> <form action={removeCategoryAction}>
@@ -316,60 +370,69 @@ export function PortfolioCategoriesManager({
}, [searchParams]); }, [searchParams]);
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<AppCard level={3}> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<div className="grid gap-4 sm:grid-cols-3"> <span>
<StatsCard title="Total" value={String(categories.length)} /> Total:
<StatsCard title="Active" value={String(activeCount)} /> {" "}
<StatsCard title="Assigned" value={String(assignedProjects)} /> <span className="font-semibold text-foreground">{categories.length}</span>
</div> </span>
<span>
Active:
{" "}
<span className="font-semibold text-foreground">{activeCount}</span>
</span>
<span>
Assigned:
{" "}
<span className="font-semibold text-foreground">{assignedProjects}</span>
</span>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<FolderPlus className="h-4 w-4" /> <FolderPlus className="h-4 w-4" />
{copy.addCategory} {copy.addCategory}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{copy.addCategory}</DialogTitle> <DialogTitle>{copy.addCategory}</DialogTitle>
<DialogDescription>{copy.modalDescription}</DialogDescription> <DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader> </DialogHeader>
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<AppCard level={2} padding="sm" className="rounded-nested"> <div className="space-y-1">
<Sparkles className="h-4 w-4 text-brand-primary" /> <p className="text-sm font-medium text-foreground">1. Basics</p>
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p> <p className="text-sm text-muted-foreground">Slug and sort order first.</p>
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p> </div>
</AppCard> <div className="space-y-1">
<AppCard level={2} padding="sm" className="rounded-nested"> <p className="text-sm font-medium text-foreground">2. Localization</p>
<Layers3 className="h-4 w-4 text-brand-primary" /> <p className="text-sm text-muted-foreground">Names and descriptions in all locales.</p>
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p> </div>
<p className="mt-1 text-sm text-muted-foreground">Keep names and descriptions complete.</p> <div className="space-y-1">
</AppCard> <p className="text-sm font-medium text-foreground">3. Visibility</p>
<AppCard level={2} padding="sm" className="rounded-nested"> <p className="text-sm text-muted-foreground">Decide if the category is public or hidden.</p>
<Pencil className="h-4 w-4 text-brand-primary" /> </div>
<p className="mt-3 text-sm font-medium text-foreground">Publish when ready</p>
<p className="mt-1 text-sm text-muted-foreground">Categories stay manageable from day one.</p>
</AppCard>
</div> </div>
</AppCard>
<CategoryForm <CategoryForm
formId="portfolio-category-create-form" formId="portfolio-category-create-form"
action={saveCategoryAction} action={saveCategoryAction}
/> />
<DialogFooter> <DialogFooter>
<Button type="submit" form="portfolio-category-create-form"> <Button type="submit" form="portfolio-category-create-form">
{copy.saveCategory} {copy.saveCategory}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardContent> </div>
</AppCard>
<AppCard level={3}> <AppCard level={3}>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
@@ -381,66 +444,51 @@ export function PortfolioCategoriesManager({
{categories.length === 0 ? ( {categories.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.empty}</p> <p className="text-sm text-muted-foreground">{copy.empty}</p>
) : ( ) : (
<Accordion type="single" collapsible className="space-y-3"> <div className="grid gap-3">
{categories.map((category) => ( {categories.map((category) => (
<AccordionItem key={category.id} value={category.id}> <AppCard
<AccordionTrigger className="bg-surface-2 hover:no-underline"> key={category.id}
<div className="flex min-w-0 flex-1 flex-col gap-3 text-left lg:flex-row lg:items-center lg:justify-between"> level={2}
<div className="space-y-1"> padding="sm"
<div className="flex flex-wrap items-center gap-2"> className="rounded-nested"
<span className="text-base font-semibold text-foreground"> >
{category.name.de || category.name.en || category.name.ar} <div className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
</span> <div className="min-w-0 space-y-1">
<Badge variant={category.isActive ? "success" : "warning"}> <div className="flex flex-wrap items-center gap-2">
{category.isActive ? "Active" : "Inactive"} <span className="truncate text-base font-semibold text-foreground">
</Badge> {category.name.de || category.name.en || category.name.ar}
</div> </span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <Badge variant={category.isActive ? "success" : "warning"}>
<span>{category.slug}</span> {category.isActive ? "Active" : "Inactive"}
<span>{copy.projects}: {category.projectCount}</span> </Badge>
<span>{copy.sortOrder}: {category.sortOrder}</span> </div>
</div> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{category.slug}</span>
<span>{copy.projects}: {category.projectCount}</span>
<span>{copy.sortOrder}: {category.sortOrder}</span>
</div> </div>
<Button
type="button"
variant="outline"
onClick={(event) => {
event.preventDefault();
setEditingCategoryId(category.id);
}}
>
<Pencil className="h-4 w-4" />
Edit
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => (
<AppCard key={`${category.id}-${locale.key}`} level={2} padding="sm" className="space-y-3 rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="text-sm font-semibold text-foreground">
{category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
<p className="text-sm leading-6 text-muted-foreground">
{category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
</AppCard>
))}
</div> </div>
<EditCategoryDialog <Button
category={category} type="button"
open={editingCategoryId === category.id} variant="outline"
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)} onClick={() => setEditingCategoryId(category.id)}
saveCategoryAction={saveCategoryAction} >
removeCategoryAction={removeCategoryAction} <Pencil className="h-4 w-4" />
/> Edit
</AccordionContent> </Button>
</AccordionItem> </div>
<EditCategoryDialog
category={category}
open={editingCategoryId === category.id}
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
saveCategoryAction={saveCategoryAction}
removeCategoryAction={removeCategoryAction}
/>
</AppCard>
))} ))}
</Accordion> </div>
)} )}
</CardContent> </CardContent>
</AppCard> </AppCard>
@@ -21,6 +21,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getAdminAppPath } from "@/lib/admin-routing";
const copy = { const copy = {
editProject: "Bearbeiten", editProject: "Bearbeiten",
@@ -40,7 +41,7 @@ export function PortfolioProjectActions({ projectId }: { projectId: string }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/portfolio/projects/${projectId}`}> <Link href={getAdminAppPath(`/portfolio/projects/${projectId}`)}>
<FolderKanban className="mr-2 h-4 w-4" /> <FolderKanban className="mr-2 h-4 w-4" />
{copy.editProject} {copy.editProject}
</Link> </Link>
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -22,16 +22,30 @@ function useTabsContext() {
} }
type TabsProps = { type TabsProps = {
defaultValue: string; defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
}; };
function Tabs({ defaultValue, children, className }: TabsProps) { function Tabs({ defaultValue, value, onValueChange, children, className }: TabsProps) {
const [value, setValue] = React.useState(defaultValue); const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const activeValue = value ?? internalValue;
const setValue = React.useCallback(
(nextValue: string) => {
if (value === undefined) {
setInternalValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, value],
);
return ( return (
<TabsContext.Provider value={{ value, setValue }}> <TabsContext.Provider value={{ value: activeValue, setValue }}>
<div className={cn("w-full", className)}>{children}</div> <div className={cn("w-full", className)}>{children}</div>
</TabsContext.Provider> </TabsContext.Provider>
); );
+1 -1
View File
@@ -138,7 +138,7 @@ export function getAdminNavigation(
active: portfolioChild === "new-project", active: portfolioChild === "new-project",
}, },
{ {
label: "Add Category", label: "Categories",
href: getAdminAppPath("/portfolio/categories"), href: getAdminAppPath("/portfolio/categories"),
icon: Tags, icon: Tags,
active: portfolioChild === "categories", active: portfolioChild === "categories",
+154
View File
@@ -0,0 +1,154 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets";
export type PortfolioWizardStepState = {
key: PortfolioWizardStep;
complete: boolean;
summary: string;
};
type ProjectBasicsProgressInput = {
categoryId: string;
slug: string;
clientName: string;
projectYear: string;
sortOrder: string;
viewMode: PortfolioProjectViewMode;
};
type ProjectContentProgressInput = {
titleAr: string;
titleEn: string;
titleDe: string;
serviceLabelAr: string;
serviceLabelEn: string;
serviceLabelDe: string;
summaryAr: string;
summaryEn: string;
summaryDe: string;
};
type ProjectSectionProgressInput = {
type: PortfolioSectionType;
titleAr: string;
titleEn: string;
titleDe: string;
bodyAr: string;
bodyEn: string;
bodyDe: string;
linkUrl: string;
mediaAssetId: string;
};
type ProjectAssetProgressInput = {
mediaAssetId: string;
altAr: string;
altEn: string;
altDe: string;
};
type PortfolioWizardProgressInput = {
basics: ProjectBasicsProgressInput;
content: ProjectContentProgressInput;
sections: ProjectSectionProgressInput[];
assets: ProjectAssetProgressInput[];
};
function hasText(value: string) {
return value.trim().length > 0;
}
function isValidSlug(value: string) {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim());
}
function isValidYear(value: string) {
const year = Number(value);
return Number.isInteger(year) && year >= 2000 && year <= 2100;
}
function isValidSortOrder(value: string) {
const sortOrder = Number(value);
return Number.isInteger(sortOrder) && sortOrder >= 0 && sortOrder <= 9999;
}
export function isPortfolioSectionReady(section: ProjectSectionProgressInput) {
const hasTitles = hasText(section.titleAr) && hasText(section.titleEn) && hasText(section.titleDe);
if (!hasTitles) {
return false;
}
if (section.type === "GALLERY") {
return hasText(section.mediaAssetId);
}
if (section.type === "LINK") {
return hasText(section.linkUrl);
}
return hasText(section.bodyAr) && hasText(section.bodyEn) && hasText(section.bodyDe);
}
export function isPortfolioAssetReady(asset: ProjectAssetProgressInput) {
return (
hasText(asset.mediaAssetId) &&
hasText(asset.altAr) &&
hasText(asset.altEn) &&
hasText(asset.altDe)
);
}
export function getPortfolioWizardProgress(
input: PortfolioWizardProgressInput,
): PortfolioWizardStepState[] {
const completedSections = input.sections.filter(isPortfolioSectionReady).length;
const completedAssets = input.assets.filter(isPortfolioAssetReady).length;
return [
{
key: "basics",
complete:
hasText(input.basics.categoryId) &&
isValidSlug(input.basics.slug) &&
hasText(input.basics.clientName) &&
isValidYear(input.basics.projectYear) &&
isValidSortOrder(input.basics.sortOrder) &&
Boolean(input.basics.viewMode),
summary: "Category, slug, client, year, and order are set.",
},
{
key: "content",
complete:
hasText(input.content.titleAr) &&
hasText(input.content.titleEn) &&
hasText(input.content.titleDe) &&
hasText(input.content.serviceLabelAr) &&
hasText(input.content.serviceLabelEn) &&
hasText(input.content.serviceLabelDe) &&
hasText(input.content.summaryAr) &&
hasText(input.content.summaryEn) &&
hasText(input.content.summaryDe),
summary: "Title, service label, and summary exist in all locales.",
},
{
key: "sections",
complete: input.sections.length > 0 && completedSections === input.sections.length,
summary: `${completedSections}/${input.sections.length} sections ready.`,
},
{
key: "assets",
complete: input.assets.length > 0 && completedAssets === input.assets.length,
summary: `${completedAssets}/${input.assets.length} assets ready.`,
},
];
}
export function getFirstIncompleteWizardStep(
steps: PortfolioWizardStepState[],
): PortfolioWizardStep | null {
return steps.find((step) => !step.complete)?.key ?? null;
}
+89
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import {
getFirstIncompleteWizardStep,
getPortfolioWizardProgress,
isPortfolioAssetReady,
isPortfolioSectionReady,
} from "../lib/portfolio-form-progress";
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
@@ -203,4 +209,87 @@ describe("portfolio validation", () => {
}), }),
).toThrow(/link/i); ).toThrow(/link/i);
}); });
it("calculates wizard progress and points to the first incomplete step", () => {
const progress = getPortfolioWizardProgress({
basics: {
categoryId: "cat_1",
slug: "case-study-entry",
clientName: "Client",
projectYear: "2025",
sortOrder: "1",
viewMode: "GRID",
},
content: {
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
serviceLabelAr: "خدمة",
serviceLabelEn: "Service",
serviceLabelDe: "Service",
summaryAr: "",
summaryEn: "",
summaryDe: "",
},
sections: [
{
type: "RICH_TEXT",
titleAr: "العنوان",
titleEn: "Title",
titleDe: "Titel",
bodyAr: "النص",
bodyEn: "Body",
bodyDe: "Text",
linkUrl: "",
mediaAssetId: "",
},
],
assets: [
{
mediaAssetId: "asset_1",
altAr: "بديل",
altEn: "Alt",
altDe: "Alt",
},
],
});
expect(progress.find((step) => step.key === "basics")?.complete).toBe(true);
expect(progress.find((step) => step.key === "content")?.complete).toBe(false);
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
});
it("marks sections and assets ready only when required fields are present", () => {
expect(
isPortfolioSectionReady({
type: "LINK",
titleAr: "رابط",
titleEn: "Link",
titleDe: "Link",
bodyAr: "",
bodyEn: "",
bodyDe: "",
linkUrl: "https://example.com",
mediaAssetId: "",
}),
).toBe(true);
expect(
isPortfolioAssetReady({
mediaAssetId: "asset_1",
altAr: "بديل",
altEn: "Alt",
altDe: "Alt",
}),
).toBe(true);
expect(
isPortfolioAssetReady({
mediaAssetId: "",
altAr: "بديل",
altEn: "Alt",
altDe: "Alt",
}),
).toBe(false);
});
}); });