feat: Add document template management functionality

- Created migration script to add `docx_templates` table with necessary fields and indexes.
- Implemented API routes for uploading, fetching, and deleting document templates.
- Developed document generation feature using selected templates and project data.
- Added UI components for template upload and listing, including a modal for document generation.
- Integrated document generation into the project view page, allowing users to generate documents based on selected templates.
- Enhanced error handling and user feedback for template operations.
This commit is contained in:
2025-12-16 09:50:19 +01:00
parent abad26b68a
commit c0d357efdd
12 changed files with 1291 additions and 3 deletions

155
DOCX_TEMPLATES_README.md Normal file
View File

@@ -0,0 +1,155 @@
# DOCX Template System
This system allows you to generate DOCX documents by filling templates with project data.
## How to Create Templates
1. **Create a DOCX Template**: Use Microsoft Word or any DOCX editor to create your template.
2. **Add Placeholders**: Use single curly braces `{variableName}` to mark where data should be inserted. Available variables:
### Available Variables (with duplicates for repeated use)
#### Project Information
- `{project_name}`, `{project_name_1}`, `{project_name_2}`, `{project_name_3}` - Project name
- `{project_number}`, `{project_number_1}`, `{project_number_2}` - Project number
- `{address}`, `{address_1}`, `{address_2}` - Project address
- `{city}`, `{city_1}`, `{city_2}` - City
- `{plot}` - Plot number
- `{district}` - District
- `{unit}` - Unit
- `{investment_number}` - Investment number
- `{wp}` - WP number
- `{coordinates}` - GPS coordinates
- `{notes}` - Project notes
#### Processed/Transformed Fields
- `{investment_number_short}` - Last part of investment number after last dash (e.g., "1234567" from "I-BC-DE-1234567")
- `{project_number_short}` - Last part of project number after last dash
- `{project_name_upper}` - Project name in uppercase
- `{project_name_lower}` - Project name in lowercase
- `{city_upper}` - City name in uppercase
- `{customer_upper}` - Customer name in uppercase
#### Contract Information
- `{contract_number}` - Contract number
- `{customer_contract_number}` - Customer contract number
- `{customer}`, `{customer_1}`, `{customer_2}` - Customer name
- `{investor}` - Investor name
#### Dates
- `{finish_date}` - Finish date (formatted)
- `{completion_date}` - Completion date (formatted)
- `{today_date}` - Today's date
#### Project Type & Status
- `{project_type}` - Project type (design/construction/design+construction)
- `{project_status}` - Project status
#### Financial
- `{wartosc_zlecenia}`, `{wartosc_zlecenia_1}`, `{wartosc_zlecenia_2}` - Contract value
#### Contacts
- `{contacts}` - Array of contacts (use loops in advanced templates)
- `{primary_contact}` - Primary contact name
- `{primary_contact_phone}` - Primary contact phone
- `{primary_contact_email}` - Primary contact email
## Example Template Content
```
Project Report
Project Name: {project_name} ({project_name_upper})
Project Number: {project_number} (Short: {project_number_short})
Location: {city_upper}, {address}
Investment Details:
Full Investment Number: {investment_number}
Short Investment Number: {investment_number_short}
Contract Details:
Contract Number: {contract_number}
Customer: {customer} ({customer_upper})
Value: {wartosc_zlecenia} PLN
Custom Information:
Meeting Notes: {meeting_notes}
Special Instructions: {special_instructions}
Additional Comments: {additional_comments}
Primary Contact:
Name: {primary_contact}
Phone: {primary_contact_phone}
Email: {primary_contact_email}
Generated on: {today_date}
```
## Uploading Templates
1. Go to the Templates page (`/templates`)
2. Click "Add Template"
3. Provide a name and description
4. Upload your DOCX file
5. The template will be available for generating documents
## Generating Documents
1. Open any project page
2. In the sidebar, find the "Generate Document" section
3. Select a template from the dropdown
4. **Optional**: Click "Pokaż dodatkowe pola" to add custom data
5. Add any custom fields you need (e.g., `custom_note`, `additional_info`)
6. Click "Generate Document"
7. The filled document will be downloaded automatically with filename: `{template_name}_{project_name}_{timestamp}.docx`
## Custom Data Fields
During document generation, you can add custom data that will be merged with the project data:
- **Custom fields** override project data if they have the same name
- Use descriptive names like `meeting_notes`, `special_instructions`, `custom_date`
- Custom fields are available in templates as `{custom_field_name}`
- Empty custom fields are ignored
### Example Custom Fields:
- `meeting_notes`: "Please bring project documentation"
- `special_instructions`: "Use company letterhead"
- `custom_date`: "2025-01-15"
- `additional_comments`: "Follow up required"
## Template Syntax
The system uses `docxtemplater` library which supports:
- Simple variable replacement: `{variable}`
- Loops: `{#contacts}{name}{/contacts}`
- Conditions: `{#primary_contact}Primary: {name}{/primary_contact}`
- Formatting and styling from your DOCX template is preserved
## Data Processing & Transformations
The system automatically provides processed versions of common fields:
- **Short codes**: `{investment_number_short}` extracts the last segment after dashes (e.g., "1234567" from "I-BC-DE-1234567")
- **Case transformations**: `{project_name_upper}`, `{city_upper}`, `{customer_upper}` for uppercase versions
- **Duplicate fields**: Multiple versions of the same field for repeated use (`{project_name_1}`, `{project_name_2}`, etc.)
If you need additional transformations (like extracting different parts of codes, custom formatting, calculations, etc.), please let us know and we can add them to the system.
## Tips
- Test your templates with sample data first
- Use descriptive variable names
- Keep formatting simple for best results
- Save templates with `.docx` extension only
- Maximum file size: 10MB
- **For repeated information**: If you need the same data to appear multiple times, create unique placeholders like `{project_name_header}` and `{project_name_footer}` and provide the same value for both
## Troubleshooting
- **Duplicate tags error**: Each placeholder can only be used once in the template. If you need the same information to appear multiple times, use unique placeholders like `{project_name_1}` and `{project_name_2}` with the same data value.
- **Template not rendering**: Check for syntax errors in placeholders
- **Missing data**: Ensure the project has the required information
- **Formatting issues**: Try simplifying the template formatting
- **File not downloading**: Check browser popup blockers

View File

@@ -0,0 +1,38 @@
import db from "./src/lib/db.js";
// Migration to add docx_templates table
const migration = () => {
console.log("Running migration: add-docx-templates-table");
try {
db.exec(`
-- Table: docx_templates
CREATE TABLE IF NOT EXISTS docx_templates (
template_id INTEGER PRIMARY KEY AUTOINCREMENT,
template_name TEXT NOT NULL,
description TEXT,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
);
-- Indexes for templates
CREATE INDEX IF NOT EXISTS idx_docx_templates_active ON docx_templates(is_active);
CREATE INDEX IF NOT EXISTS idx_docx_templates_created_by ON docx_templates(created_by);
`);
console.log("Migration completed successfully");
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
};
migration();

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { unlink } from "fs/promises";
import path from "path";
import db from "@/lib/db";
export async function DELETE(request, { params }) {
try {
const { templateId } = params;
// Get template info
const template = db.prepare(`
SELECT * FROM docx_templates WHERE template_id = ?
`).get(templateId);
if (!template) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
// Soft delete by setting is_active to 0
db.prepare(`
UPDATE docx_templates
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
WHERE template_id = ?
`).run(templateId);
// Optionally delete the file (uncomment if you want hard delete)
// try {
// const filePath = path.join(process.cwd(), "public", template.file_path);
// await unlink(filePath);
// } catch (fileError) {
// console.warn("Could not delete template file:", fileError);
// }
return NextResponse.json({ success: true });
} catch (error) {
console.error("Template deletion error:", error);
return NextResponse.json(
{ error: "Failed to delete template" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,209 @@
import { NextRequest, NextResponse } from "next/server";
import PizZip from "pizzip";
import Docxtemplater from "docxtemplater";
import { readFile, writeFile } from "fs/promises";
import path from "path";
import db from "@/lib/db";
import { formatDate, formatCoordinates } from "@/lib/utils";
export async function POST(request) {
try {
const { templateId, projectId, customData } = await request.json();
if (!templateId || !projectId) {
return NextResponse.json(
{ error: "templateId and projectId are required" },
{ status: 400 }
);
}
// Get template
const template = db.prepare(`
SELECT * FROM docx_templates WHERE template_id = ? AND is_active = 1
`).get(templateId);
if (!template) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
// Get project data
const project = db.prepare(`
SELECT
p.*,
c.contract_number,
c.customer_contract_number,
c.customer,
c.investor
FROM projects p
LEFT JOIN contracts c ON p.contract_id = c.contract_id
WHERE p.project_id = ?
`).get(projectId);
if (!project) {
return NextResponse.json(
{ error: "Project not found" },
{ status: 404 }
);
}
// Get project contacts
const contacts = db.prepare(`
SELECT
pc.*,
ct.name,
ct.phone,
ct.email,
ct.company,
ct.contact_type
FROM project_contacts pc
JOIN contacts ct ON pc.contact_id = ct.contact_id
WHERE pc.project_id = ?
ORDER BY pc.is_primary DESC, ct.name
`).all(projectId);
// Load template file
const templatePath = path.join(process.cwd(), "public", template.file_path);
const templateContent = await readFile(templatePath);
// Load the docx file as a binary
const zip = new PizZip(templateContent);
const doc = new Docxtemplater(zip, {
paragraphLoop: true,
linebreaks: true,
});
// Prepare data for template
const templateData = {
// Project basic info
project_name: project.project_name || "",
project_number: project.project_number || "",
address: project.address || "",
city: project.city || "",
plot: project.plot || "",
district: project.district || "",
unit: project.unit || "",
investment_number: project.investment_number || "",
wp: project.wp || "",
coordinates: project.coordinates || "",
notes: project.notes || "",
// Processed fields (extracted/transformed data)
investment_number_short: project.investment_number ? project.investment_number.split('-').pop() : "",
project_number_short: project.project_number ? project.project_number.split('-').pop() : "",
project_name_upper: project.project_name ? project.project_name.toUpperCase() : "",
project_name_lower: project.project_name ? project.project_name.toLowerCase() : "",
city_upper: project.city ? project.city.toUpperCase() : "",
customer_upper: project.customer ? project.customer.toUpperCase() : "",
// Contract info
contract_number: project.contract_number || "",
customer_contract_number: project.customer_contract_number || "",
customer: project.customer || "",
investor: project.investor || "",
// Dates
finish_date: project.finish_date ? formatDate(project.finish_date) : "",
completion_date: project.completion_date ? formatDate(project.completion_date) : "",
today_date: formatDate(new Date()),
// Project type and status
project_type: project.project_type || "",
project_status: project.project_status || "",
// Financial
wartosc_zlecenia: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
// Contacts
contacts: contacts.map(contact => ({
name: contact.name || "",
phone: contact.phone || "",
email: contact.email || "",
company: contact.company || "",
contact_type: contact.contact_type || "",
is_primary: contact.is_primary ? "Tak" : "Nie"
})),
// Primary contact
primary_contact: contacts.find(c => c.is_primary)?.name || "",
primary_contact_phone: contacts.find(c => c.is_primary)?.phone || "",
primary_contact_email: contacts.find(c => c.is_primary)?.email || "",
// Duplicate fields for repeated use (common fields that users might want to repeat)
project_name_1: project.project_name || "",
project_name_2: project.project_name || "",
project_name_3: project.project_name || "",
project_number_1: project.project_number || "",
project_number_2: project.project_number || "",
customer_1: project.customer || "",
customer_2: project.customer || "",
address_1: project.address || "",
address_2: project.address || "",
city_1: project.city || "",
city_2: project.city || "",
wartosc_zlecenia_1: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
wartosc_zlecenia_2: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
};
// Merge custom data (custom data takes precedence over project data)
if (customData && typeof customData === 'object') {
Object.assign(templateData, customData);
}
// Set the template variables
doc.setData(templateData);
try {
// Render the document
doc.render();
} catch (error) {
console.error("Template rendering error:", error);
// Check if it's a duplicate tags error
if (error.name === 'TemplateError' && error.properties?.id === 'duplicate_open_tag') {
return NextResponse.json(
{
error: "Template contains duplicate placeholders. Each placeholder (like {{project_name}}) can only be used once in the template. Please modify your DOCX template to use unique placeholders or remove duplicates.",
details: `Duplicate tag found: ${error.properties?.xtag || 'unknown'}`
},
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Failed to render template. Please check template syntax and ensure all placeholders are properly formatted." },
{ status: 400 }
);
}
// Get the generated document
const buf = doc.getZip().generate({
type: "nodebuffer",
compression: "DEFLATE",
});
// Generate filename
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
const sanitizedTemplateName = template.template_name.replace(/[^a-zA-Z0-9]/g, "_");
const sanitizedProjectName = project.project_name.replace(/[^a-zA-Z0-9]/g, "_");
const filename = `${sanitizedTemplateName}_${sanitizedProjectName}_${timestamp}.docx`;
// Return the file as a downloadable response
return new NextResponse(buf, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error("Template generation error:", error);
return NextResponse.json(
{ error: "Failed to generate document" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir, unlink } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import db from "@/lib/db";
const TEMPLATES_DIR = path.join(process.cwd(), "public", "templates");
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
];
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const templateName = formData.get("templateName");
const description = formData.get("description") || "";
if (!file || !templateName) {
return NextResponse.json(
{ error: "File and templateName are required" },
{ status: 400 }
);
}
// Validate file
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "File size too large (max 10MB)" },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Only DOCX files are allowed" },
{ status: 400 }
);
}
// Create templates directory
if (!existsSync(TEMPLATES_DIR)) {
await mkdir(TEMPLATES_DIR, { recursive: true });
}
// Generate unique filename
const timestamp = Date.now();
const sanitizedOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const storedFilename = `${timestamp}_${sanitizedOriginalName}`;
const filePath = path.join(TEMPLATES_DIR, storedFilename);
const relativePath = `/templates/${storedFilename}`;
// Save file
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filePath, buffer);
// Save to database
const stmt = db.prepare(`
INSERT INTO docx_templates (
template_name, description, original_filename, stored_filename,
file_path, file_size, mime_type, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
templateName,
description,
file.name,
storedFilename,
relativePath,
file.size,
file.type,
null // TODO: Get from session when auth is implemented
);
const newTemplate = {
template_id: result.lastInsertRowid,
template_name: templateName,
description: description,
original_filename: file.name,
stored_filename: storedFilename,
file_path: relativePath,
file_size: file.size,
mime_type: file.type,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return NextResponse.json(newTemplate, { status: 201 });
} catch (error) {
console.error("Template upload error:", error);
return NextResponse.json(
{ error: "Failed to upload template" },
{ status: 500 }
);
}
}
export async function GET(request) {
try {
const templates = db.prepare(`
SELECT
template_id,
template_name,
description,
original_filename,
stored_filename,
file_path,
file_size,
mime_type,
is_active,
created_at,
created_by,
updated_at
FROM docx_templates
WHERE is_active = 1
ORDER BY created_at DESC
`).all();
return NextResponse.json(templates);
} catch (error) {
console.error("Error fetching templates:", error);
return NextResponse.json(
{ error: "Failed to fetch templates" },
{ status: 500 }
);
}
}

View File

@@ -19,6 +19,7 @@ import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
import ClientProjectMap from "@/components/ui/ClientProjectMap";
import FileUploadBox from "@/components/FileUploadBox";
import FileItem from "@/components/FileItem";
import DocumentGenerator from "@/components/DocumentGenerator";
import proj4 from "proj4";
export default function ProjectViewPage() {
@@ -31,6 +32,7 @@ export default function ProjectViewPage() {
const [editingNoteId, setEditingNoteId] = useState(null);
const [editText, setEditText] = useState('');
const [projectContacts, setProjectContacts] = useState([]);
const [showDocumentModal, setShowDocumentModal] = useState(false);
// Helper function to parse note text with links
const parseNoteText = (text) => {
@@ -800,6 +802,27 @@ export default function ProjectViewPage() {
Zobacz wszystkie na mapie
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => setShowDocumentModal(true)}
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Generuj dokument
</Button>
</CardContent>
</Card>
@@ -816,9 +839,9 @@ export default function ProjectViewPage() {
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
{uploadedFiles.map((file) => (
<FileItem
key={file.file_id}
file={file}
<FileItem
key={file.file_id}
file={file}
onDelete={handleFileDelete}
onUpdate={handleFileUpdate}
/>
@@ -827,6 +850,7 @@ export default function ProjectViewPage() {
)}
</CardContent>
</Card>
</div>
</div>
{/* Project Location Map */}
@@ -1045,6 +1069,31 @@ export default function ProjectViewPage() {
)}
</CardContent>
</Card>
{/* Document Generator Modal */}
{showDocumentModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => e.target === e.currentTarget && setShowDocumentModal(false)}
>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Generuj dokument
</h3>
<button
onClick={() => setShowDocumentModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<DocumentGenerator projectId={params.id} />
</div>
</div>
)}
</PageContainer>
);
}

View File

@@ -274,6 +274,24 @@ export default function ProjectListPage() {
</svg>
</Button>
</Link>
<Link href="/templates" title="Szablony dokumentów">
<Button variant="ghost" size="icon" className="h-10 w-10">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</Button>
</Link>
<Button
variant="ghost"

91
src/app/templates/page.js Normal file
View File

@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect } from "react";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import TemplateUploadForm from "@/components/TemplateUploadForm";
import TemplateList from "@/components/TemplateList";
export default function TemplatesPage() {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [showUploadForm, setShowUploadForm] = useState(false);
useEffect(() => {
fetchTemplates();
}, []);
const fetchTemplates = async () => {
try {
const response = await fetch("/api/templates");
if (response.ok) {
const data = await response.json();
setTemplates(data);
}
} catch (error) {
console.error("Error fetching templates:", error);
} finally {
setLoading(false);
}
};
const handleTemplateUploaded = (newTemplate) => {
setTemplates(prev => [newTemplate, ...prev]);
setShowUploadForm(false);
};
const handleTemplateDeleted = (templateId) => {
setTemplates(prev => prev.filter(t => t.template_id !== templateId));
};
if (loading) {
return (
<PageContainer>
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading templates...</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Szablony dokumentów"
description="Zarządzaj szablonami dokumentów DOCX do generowania dokumentów z danymi projektów"
action={
<Button
variant="primary"
onClick={() => setShowUploadForm(!showUploadForm)}
>
{showUploadForm ? "Anuluj" : "Dodaj szablon"}
</Button>
}
/>
{showUploadForm && (
<div className="mb-6">
<Card>
<div className="p-6">
<TemplateUploadForm
onTemplateUploaded={handleTemplateUploaded}
onCancel={() => setShowUploadForm(false)}
/>
</div>
</Card>
</div>
)}
<Card>
<div className="p-6">
<TemplateList
templates={templates}
onTemplateDeleted={handleTemplateDeleted}
/>
</div>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { useState, useEffect } from "react";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
export default function DocumentGenerator({ projectId }) {
const [templates, setTemplates] = useState([]);
const [selectedTemplate, setSelectedTemplate] = useState("");
const [generating, setGenerating] = useState(false);
const [loading, setLoading] = useState(true);
const [customFields, setCustomFields] = useState([{ key: "", value: "" }]);
const [showCustomFields, setShowCustomFields] = useState(false);
useEffect(() => {
fetchTemplates();
}, []);
const fetchTemplates = async () => {
try {
const response = await fetch("/api/templates");
if (response.ok) {
const data = await response.json();
setTemplates(data);
}
} catch (error) {
console.error("Error fetching templates:", error);
} finally {
setLoading(false);
}
};
const handleCustomFieldChange = (index, field, value) => {
const updatedFields = [...customFields];
updatedFields[index][field] = value;
setCustomFields(updatedFields);
};
const addCustomField = () => {
setCustomFields([...customFields, { key: "", value: "" }]);
};
const removeCustomField = (index) => {
if (customFields.length > 1) {
setCustomFields(customFields.filter((_, i) => i !== index));
}
};
const getCustomData = () => {
const customData = {};
customFields.forEach(field => {
if (field.key.trim()) {
customData[field.key.trim()] = field.value;
}
});
return customData;
};
const handleGenerate = async () => {
if (!selectedTemplate) {
alert("Proszę wybrać szablon");
return;
}
setGenerating(true);
try {
const customData = getCustomData();
const response = await fetch("/api/templates/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
templateId: selectedTemplate,
projectId: projectId,
customData: customData,
}),
});
if (response.ok) {
// Create a blob from the response and download it
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Get filename from response headers
const contentDisposition = response.headers.get("Content-Disposition");
let filename = "generated_document.docx";
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
const error = await response.json();
let errorMessage = error.error;
// Provide more helpful error messages
if (error.details) {
errorMessage += `\n\nSzczegóły: ${error.details}`;
}
if (error.error.includes("duplicate")) {
errorMessage += "\n\nRozwiązanie: Użyj unikalnych nazw dla każdego wystąpienia tej samej informacji, np. {{project_name_1}} i {{project_name_2}}.";
}
alert(`Błąd podczas generowania dokumentu:\n\n${errorMessage}`);
}
} catch (error) {
console.error("Generation error:", error);
alert("Błąd podczas generowania dokumentu");
} finally {
setGenerating(false);
}
};
if (loading) {
return (
<div className="text-sm text-gray-500">Ładowanie szablonów...</div>
);
}
if (templates.length === 0) {
return (
<div className="text-sm text-gray-500">
Brak dostępnych szablonów.{" "}
<a
href="/templates"
className="text-blue-600 hover:text-blue-800 underline"
target="_blank"
>
Dodaj szablon
</a>
</div>
);
}
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Wybierz szablon
</label>
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">-- Wybierz szablon --</option>
{templates.map((template) => (
<option key={template.template_id} value={template.template_id}>
{template.template_name}
</option>
))}
</select>
</div>
{/* Custom Fields Section */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">
Dodatkowe dane (opcjonalne)
</label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCustomFields(!showCustomFields)}
className="text-xs"
>
{showCustomFields ? "Ukryj" : "Pokaż"} dodatkowe pola
</Button>
</div>
{showCustomFields && (
<div className="space-y-2 border border-gray-200 rounded-md p-3 bg-gray-50">
{customFields.map((field, index) => (
<div key={index} className="flex gap-2 items-center">
<input
type="text"
placeholder="Nazwa pola (np. custom_note)"
value={field.key}
onChange={(e) => handleCustomFieldChange(index, 'key', e.target.value)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
<input
type="text"
placeholder="Wartość"
value={field.value}
onChange={(e) => handleCustomFieldChange(index, 'value', e.target.value)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
{customFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeCustomField(index)}
className="text-red-600 hover:text-red-800 p-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addCustomField}
className="w-full text-xs"
>
+ Dodaj pole
</Button>
<div className="text-xs text-gray-500 mt-2">
Wprowadź dodatkowe dane, które będą dostępne w szablonie jako {"{custom_note}"}, {"{additional_info}"} itp.
</div>
</div>
)}
</div>
<Button
onClick={handleGenerate}
disabled={!selectedTemplate || generating}
variant="primary"
size="sm"
className="w-full"
>
{generating ? "Generowanie..." : "Generuj dokument"}
</Button>
<div className="text-xs text-gray-500">
Dokument zostanie wygenerowany na podstawie danych projektu i pobrany automatycznie.
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
export default function TemplateList({ templates, onTemplateDeleted }) {
const [deletingId, setDeletingId] = useState(null);
const handleDelete = async (templateId) => {
if (!confirm("Czy na pewno chcesz usunąć ten szablon?")) {
return;
}
setDeletingId(templateId);
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: "DELETE",
});
if (response.ok) {
onTemplateDeleted(templateId);
} else {
alert("Błąd podczas usuwania szablonu");
}
} catch (error) {
console.error("Delete error:", error);
alert("Błąd podczas usuwania szablonu");
} finally {
setDeletingId(null);
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString("pl-PL", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
if (templates.length === 0) {
return (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Brak szablonów
</h3>
<p className="text-gray-500">
Dodaj swój pierwszy szablon dokumentów DOCX.
</p>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">
Dostępne szablony ({templates.length})
</h3>
<div className="space-y-3">
{templates.map((template) => (
<div
key={template.template_id}
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">
{template.template_name}
</h4>
<Badge variant="success" size="xs">
Aktywny
</Badge>
</div>
{template.description && (
<p className="text-sm text-gray-600 mb-2">
{template.description}
</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
Plik: {template.original_filename}
</span>
<span>
Rozmiar: {formatFileSize(template.file_size)}
</span>
<span>
Dodano: {formatDate(template.created_at)}
</span>
</div>
</div>
<div className="flex gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => window.open(template.file_path, "_blank")}
>
Pobierz
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(template.template_id)}
disabled={deletingId === template.template_id}
>
{deletingId === template.template_id ? "Usuwanie..." : "Usuń"}
</Button>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useRef } from "react";
import Button from "@/components/ui/Button";
export default function TemplateUploadForm({ onTemplateUploaded, onCancel }) {
const [uploading, setUploading] = useState(false);
const [formData, setFormData] = useState({
templateName: "",
description: "",
});
const fileInputRef = useRef(null);
const handleSubmit = async (e) => {
e.preventDefault();
const file = fileInputRef.current?.files[0];
if (!file) {
alert("Proszę wybrać plik DOCX");
return;
}
if (!formData.templateName.trim()) {
alert("Proszę podać nazwę szablonu");
return;
}
setUploading(true);
try {
const uploadData = new FormData();
uploadData.append("file", file);
uploadData.append("templateName", formData.templateName);
uploadData.append("description", formData.description);
const response = await fetch("/api/templates", {
method: "POST",
body: uploadData,
});
if (response.ok) {
const newTemplate = await response.json();
onTemplateUploaded(newTemplate);
setFormData({ templateName: "", description: "" });
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} else {
const error = await response.json();
alert(`Błąd podczas przesyłania: ${error.error}`);
}
} catch (error) {
console.error("Upload error:", error);
alert("Błąd podczas przesyłania szablonu");
} finally {
setUploading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa szablonu *
</label>
<input
type="text"
name="templateName"
value={formData.templateName}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="np. Umowa projektowa"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<textarea
name="description"
value={formData.description}
onChange={handleInputChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Opcjonalny opis szablonu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plik DOCX *
</label>
<input
type="file"
ref={fileInputRef}
accept=".docx"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
<p className="text-sm text-gray-500 mt-1">
Wybierz plik szablonu DOCX (max 10MB)
</p>
</div>
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={uploading}
>
{uploading ? "Przesyłanie..." : "Prześlij szablon"}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={uploading}
>
Anuluj
</Button>
</div>
</form>
);
}

View File

@@ -554,5 +554,26 @@ export default function initializeDatabase() {
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
CREATE INDEX IF NOT EXISTS idx_project_contacts_project ON project_contacts(project_id);
CREATE INDEX IF NOT EXISTS idx_project_contacts_contact ON project_contacts(contact_id);
-- Table: docx_templates
CREATE TABLE IF NOT EXISTS docx_templates (
template_id INTEGER PRIMARY KEY AUTOINCREMENT,
template_name TEXT NOT NULL,
description TEXT,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
);
-- Indexes for templates
CREATE INDEX IF NOT EXISTS idx_docx_templates_active ON docx_templates(is_active);
CREATE INDEX IF NOT EXISTS idx_docx_templates_created_by ON docx_templates(created_by);
`);
}