Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RotateCcw, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TemplateVersion {
|
||||
version: number;
|
||||
content: Record<string, unknown>;
|
||||
changedBy: string | null;
|
||||
changedAt: string;
|
||||
auditLogId: string;
|
||||
}
|
||||
|
||||
interface TemplateVersionHistoryProps {
|
||||
templateId: string;
|
||||
currentVersion: number;
|
||||
onRollback: () => void;
|
||||
}
|
||||
|
||||
export function TemplateVersionHistory({
|
||||
templateId,
|
||||
currentVersion,
|
||||
onRollback,
|
||||
}: TemplateVersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<TemplateVersion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rollingBack, setRollingBack] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ data: TemplateVersion[] }>(
|
||||
`/api/v1/admin/templates/${templateId}/versions`,
|
||||
);
|
||||
setVersions(res.data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load versions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchVersions();
|
||||
}, [fetchVersions]);
|
||||
|
||||
async function handleRollback(version: number) {
|
||||
if (!confirm(`Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`)) return;
|
||||
|
||||
setRollingBack(version);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/templates/${templateId}/rollback`, {
|
||||
method: 'POST',
|
||||
body: { version },
|
||||
});
|
||||
onRollback();
|
||||
await fetchVersions();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Rollback failed');
|
||||
} finally {
|
||||
setRollingBack(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
Loading version history…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 rounded-md border border-dashed p-6 text-center">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No previous versions found. Versions are saved whenever you update the
|
||||
template content.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current version: <strong>v{currentVersion}</strong>. Click Restore to
|
||||
roll back to a previous version (creates a new version).
|
||||
</p>
|
||||
|
||||
<div className="divide-y rounded-md border">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.auditLogId}
|
||||
className="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">v{v.version}</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
Version {v.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved{' '}
|
||||
{new Date(v.changedAt).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{v.changedBy ? ` by ${v.changedBy}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRollback(v.version)}
|
||||
disabled={rollingBack === v.version}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{rollingBack === v.version ? 'Restoring…' : 'Restore'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user