582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Search,
|
|
FileText,
|
|
ExternalLink,
|
|
Copy,
|
|
FolderOpen,
|
|
Wand2,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { Button } from "@/shared/components/ui/button";
|
|
import { Input } from "@/shared/components/ui/input";
|
|
import { Label } from "@/shared/components/ui/label";
|
|
import { Textarea } from "@/shared/components/ui/textarea";
|
|
import { Badge } from "@/shared/components/ui/badge";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/shared/components/ui/card";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/shared/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/shared/components/ui/dialog";
|
|
import type { CompanyId } from "@/core/auth/types";
|
|
import type { WordTemplate, TemplateCategory } from "../types";
|
|
import { useTemplates } from "../hooks/use-templates";
|
|
import {
|
|
parsePlaceholdersFromBuffer,
|
|
parsePlaceholdersFromUrl,
|
|
} from "../services/placeholder-parser";
|
|
|
|
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
|
contract: "Contract",
|
|
memoriu: "Memoriu tehnic",
|
|
oferta: "Ofertă",
|
|
raport: "Raport",
|
|
cerere: "Cerere",
|
|
aviz: "Aviz",
|
|
scrisoare: "Scrisoare",
|
|
altele: "Altele",
|
|
};
|
|
|
|
type ViewMode = "list" | "add" | "edit";
|
|
|
|
export function WordTemplatesModule() {
|
|
const {
|
|
templates,
|
|
allTemplates,
|
|
loading,
|
|
filters,
|
|
updateFilter,
|
|
addTemplate,
|
|
updateTemplate,
|
|
cloneTemplate,
|
|
removeTemplate,
|
|
} = useTemplates();
|
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
|
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(
|
|
null,
|
|
);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (
|
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
|
) => {
|
|
if (viewMode === "edit" && editingTemplate) {
|
|
await updateTemplate(editingTemplate.id, data);
|
|
} else {
|
|
await addTemplate(data);
|
|
}
|
|
setViewMode("list");
|
|
setEditingTemplate(null);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (deletingId) {
|
|
await removeTemplate(deletingId);
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-xs text-muted-foreground">Total șabloane</p>
|
|
<p className="text-2xl font-bold">{allTemplates.length}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-xs text-muted-foreground">Beletage</p>
|
|
<p className="text-2xl font-bold">
|
|
{allTemplates.filter((t) => t.company === "beletage").length}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-xs text-muted-foreground">Urban Switch</p>
|
|
<p className="text-2xl font-bold">
|
|
{allTemplates.filter((t) => t.company === "urban-switch").length}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-xs text-muted-foreground">Studii de Teren</p>
|
|
<p className="text-2xl font-bold">
|
|
{
|
|
allTemplates.filter((t) => t.company === "studii-de-teren")
|
|
.length
|
|
}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{viewMode === "list" && (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative min-w-[200px] flex-1">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Caută șablon..."
|
|
value={filters.search}
|
|
onChange={(e) => updateFilter("search", e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={filters.category}
|
|
onValueChange={(v) =>
|
|
updateFilter("category", v as TemplateCategory | "all")
|
|
}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
|
|
(c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{CATEGORY_LABELS[c]}
|
|
</SelectItem>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={filters.company}
|
|
onValueChange={(v) => updateFilter("company", v)}
|
|
>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Toate companiile</SelectItem>
|
|
<SelectItem value="beletage">Beletage</SelectItem>
|
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
|
<SelectItem value="group">Grup</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
Se încarcă...
|
|
</p>
|
|
) : templates.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
Niciun șablon găsit. Adaugă primul șablon Word.
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{templates.map((tpl) => (
|
|
<Card key={tpl.id} className="group relative">
|
|
<CardContent className="p-4">
|
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
title="Clonează"
|
|
onClick={() => cloneTemplate(tpl.id)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => {
|
|
setEditingTemplate(tpl);
|
|
setViewMode("edit");
|
|
}}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => setDeletingId(tpl.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-muted/30">
|
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="font-medium">{tpl.name}</p>
|
|
{tpl.description && (
|
|
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
|
{tpl.description}
|
|
</p>
|
|
)}
|
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{CATEGORY_LABELS[tpl.category]}
|
|
</Badge>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
v{tpl.version}
|
|
</Badge>
|
|
{tpl.clonedFrom && (
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
Clonă
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{/* Placeholders display */}
|
|
{(tpl.placeholders ?? []).length > 0 && (
|
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
{(tpl.placeholders ?? []).map((p) => (
|
|
<span
|
|
key={p}
|
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground"
|
|
>{`{{${p}}}`}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{tpl.fileUrl && (
|
|
<a
|
|
href={tpl.fileUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
|
>
|
|
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(viewMode === "add" || viewMode === "edit") && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<TemplateForm
|
|
initial={editingTemplate ?? undefined}
|
|
onSubmit={handleSubmit}
|
|
onCancel={() => {
|
|
setViewMode("list");
|
|
setEditingTemplate(null);
|
|
}}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Delete confirmation */}
|
|
<Dialog
|
|
open={deletingId !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeletingId(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm">
|
|
Ești sigur că vrei să ștergi acest șablon? Acțiunea este
|
|
ireversibilă.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
|
Anulează
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
|
Șterge
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TemplateForm({
|
|
initial,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
initial?: WordTemplate;
|
|
onSubmit: (
|
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
|
) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [name, setName] = useState(initial?.name ?? "");
|
|
const [description, setDescription] = useState(initial?.description ?? "");
|
|
const [category, setCategory] = useState<TemplateCategory>(
|
|
initial?.category ?? "contract",
|
|
);
|
|
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
|
|
const [company, setCompany] = useState<CompanyId>(
|
|
initial?.company ?? "beletage",
|
|
);
|
|
const [version, setVersion] = useState(initial?.version ?? "1.0.0");
|
|
const [placeholdersText, setPlaceholdersText] = useState(
|
|
(initial?.placeholders ?? []).join(", "),
|
|
);
|
|
const [parsing, setParsing] = useState(false);
|
|
const [parseError, setParseError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const applyPlaceholders = (found: string[]) => {
|
|
if (found.length === 0) {
|
|
setParseError(
|
|
"Nu s-au găsit placeholder-e de forma {{VARIABILA}} în fișier.",
|
|
);
|
|
return;
|
|
}
|
|
setPlaceholdersText(found.join(", "));
|
|
setParseError(null);
|
|
};
|
|
|
|
const handleFileDetect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setParsing(true);
|
|
setParseError(null);
|
|
try {
|
|
const buffer = await file.arrayBuffer();
|
|
const found = await parsePlaceholdersFromBuffer(buffer);
|
|
applyPlaceholders(found);
|
|
} catch (err) {
|
|
setParseError(
|
|
`Eroare la parsare: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
} finally {
|
|
setParsing(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const handleUrlDetect = async () => {
|
|
if (!fileUrl) return;
|
|
setParsing(true);
|
|
setParseError(null);
|
|
try {
|
|
const found = await parsePlaceholdersFromUrl(fileUrl);
|
|
applyPlaceholders(found);
|
|
} catch (err) {
|
|
setParseError(
|
|
`Nu s-a putut accesa URL-ul (CORS sau rețea): ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
} finally {
|
|
setParsing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const placeholders = placeholdersText
|
|
.split(",")
|
|
.map((p) => p.trim())
|
|
.filter((p) => p.length > 0);
|
|
onSubmit({
|
|
name,
|
|
description,
|
|
category,
|
|
fileUrl,
|
|
company,
|
|
version,
|
|
placeholders,
|
|
clonedFrom: initial?.clonedFrom,
|
|
tags: initial?.tags ?? [],
|
|
visibility: initial?.visibility ?? "all",
|
|
});
|
|
}}
|
|
className="space-y-4"
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<Label>Nume șablon *</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="mt-1"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Categorie</Label>
|
|
<Select
|
|
value={category}
|
|
onValueChange={(v) => setCategory(v as TemplateCategory)}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{CATEGORY_LABELS[c]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>Descriere</Label>
|
|
<Textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={2}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<Label>Companie</Label>
|
|
<Select
|
|
value={company}
|
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="beletage">Beletage</SelectItem>
|
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
|
<SelectItem value="group">Grup</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Versiune</Label>
|
|
<Input
|
|
value={version}
|
|
onChange={(e) => setVersion(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>URL fișier</Label>
|
|
<div className="mt-1 flex gap-1.5">
|
|
<Input
|
|
value={fileUrl}
|
|
onChange={(e) => setFileUrl(e.target.value)}
|
|
className="flex-1"
|
|
placeholder="https://..."
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
title="Detectează placeholder-e din URL"
|
|
disabled={!fileUrl || parsing}
|
|
onClick={handleUrlDetect}
|
|
>
|
|
{parsing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Wand2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<Label>Placeholder-e detectate</Label>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
sau detectează automat:
|
|
</span>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
className="hidden"
|
|
onChange={handleFileDetect}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={parsing}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
{parsing ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "}
|
|
Parsare...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier
|
|
.docx
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
value={placeholdersText}
|
|
onChange={(e) => setPlaceholdersText(e.target.value)}
|
|
className="mt-1"
|
|
placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..."
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Variabilele din șablon de forma {"{{VARIABILA}}"} — detectate automat
|
|
sau introduse manual, separate prin virgulă.
|
|
</p>
|
|
{parseError && (
|
|
<p className="mt-1 text-xs text-destructive">{parseError}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="outline" onClick={onCancel}>
|
|
Anulează
|
|
</Button>
|
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|