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:
46
src/app/api/templates/[templateId]/route.js
Normal file
46
src/app/api/templates/[templateId]/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
209
src/app/api/templates/generate/route.js
Normal file
209
src/app/api/templates/generate/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
133
src/app/api/templates/route.js
Normal file
133
src/app/api/templates/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user