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:
155
DOCX_TEMPLATES_README.md
Normal file
155
DOCX_TEMPLATES_README.md
Normal 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
|
||||||
38
migrate-add-docx-templates-table.mjs
Normal file
38
migrate-add-docx-templates-table.mjs
Normal 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();
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
|
|||||||
import ClientProjectMap from "@/components/ui/ClientProjectMap";
|
import ClientProjectMap from "@/components/ui/ClientProjectMap";
|
||||||
import FileUploadBox from "@/components/FileUploadBox";
|
import FileUploadBox from "@/components/FileUploadBox";
|
||||||
import FileItem from "@/components/FileItem";
|
import FileItem from "@/components/FileItem";
|
||||||
|
import DocumentGenerator from "@/components/DocumentGenerator";
|
||||||
import proj4 from "proj4";
|
import proj4 from "proj4";
|
||||||
|
|
||||||
export default function ProjectViewPage() {
|
export default function ProjectViewPage() {
|
||||||
@@ -31,6 +32,7 @@ export default function ProjectViewPage() {
|
|||||||
const [editingNoteId, setEditingNoteId] = useState(null);
|
const [editingNoteId, setEditingNoteId] = useState(null);
|
||||||
const [editText, setEditText] = useState('');
|
const [editText, setEditText] = useState('');
|
||||||
const [projectContacts, setProjectContacts] = useState([]);
|
const [projectContacts, setProjectContacts] = useState([]);
|
||||||
|
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||||
|
|
||||||
// Helper function to parse note text with links
|
// Helper function to parse note text with links
|
||||||
const parseNoteText = (text) => {
|
const parseNoteText = (text) => {
|
||||||
@@ -800,6 +802,27 @@ export default function ProjectViewPage() {
|
|||||||
Zobacz wszystkie na mapie
|
Zobacz wszystkie na mapie
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -816,9 +839,9 @@ export default function ProjectViewPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
|
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
|
||||||
{uploadedFiles.map((file) => (
|
{uploadedFiles.map((file) => (
|
||||||
<FileItem
|
<FileItem
|
||||||
key={file.file_id}
|
key={file.file_id}
|
||||||
file={file}
|
file={file}
|
||||||
onDelete={handleFileDelete}
|
onDelete={handleFileDelete}
|
||||||
onUpdate={handleFileUpdate}
|
onUpdate={handleFileUpdate}
|
||||||
/>
|
/>
|
||||||
@@ -827,6 +850,7 @@ export default function ProjectViewPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Project Location Map */}
|
{/* Project Location Map */}
|
||||||
@@ -1045,6 +1069,31 @@ export default function ProjectViewPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,24 @@ export default function ProjectListPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
91
src/app/templates/page.js
Normal file
91
src/app/templates/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/components/DocumentGenerator.js
Normal file
248
src/components/DocumentGenerator.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/TemplateList.js
Normal file
146
src/components/TemplateList.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/TemplateUploadForm.js
Normal file
134
src/components/TemplateUploadForm.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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_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_project ON project_contacts(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_contacts_contact ON project_contacts(contact_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);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user