feat: introduce new PDF layouts and modules, enhance shared UI components, and add wording guidelines.
This commit is contained in:
@@ -89,7 +89,19 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI Prompting
|
||||
// 2. Distill Crawl Context (Context Filtering)
|
||||
let distilledCrawl = '';
|
||||
if (crawlContext) {
|
||||
const cachedDistilled = await cache.get<string>(`distilled_${targetUrl}`);
|
||||
if (cachedDistilled && !clearCache) {
|
||||
distilledCrawl = cachedDistilled;
|
||||
} else {
|
||||
distilledCrawl = await distillCrawlContext(crawlContext, OPENROUTER_KEY);
|
||||
await cache.set(`distilled_${targetUrl}`, distilledCrawl, 86400);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. AI Prompting
|
||||
console.log('🤖 Consultating Gemini 3 Flash...');
|
||||
const cachedAi = !clearCache ? await cache.get<any>(finalCacheKey) : null;
|
||||
let formState: any;
|
||||
@@ -108,7 +120,7 @@ async function main() {
|
||||
console.log('📦 Using cached AI response.');
|
||||
formState = cachedAi;
|
||||
} else {
|
||||
const result = await getAiEstimation(briefing, crawlContext, comments, OPENROUTER_KEY, principles, techStandards, tone);
|
||||
const result = await getAiEstimation(briefing, distilledCrawl, comments, OPENROUTER_KEY, principles, techStandards, tone);
|
||||
formState = result.state;
|
||||
usage = result.usage;
|
||||
await cache.set(finalCacheKey, formState);
|
||||
@@ -146,6 +158,32 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
async function distillCrawlContext(rawCrawl: string, apiKey: string): Promise<string> {
|
||||
if (!rawCrawl || rawCrawl.trim().length === 0) return "Keine Crawl-Daten vorhanden.";
|
||||
|
||||
console.log(' ↳ Distilling Crawl Context (Noise Filtering)...');
|
||||
const systemPrompt = `
|
||||
You are a context distiller. Your goal is to strip away HTML noise, legal footers, and generic fluff from a website crawl.
|
||||
Extract the "Company DNA" in 5-8 bullet points (GERMAN).
|
||||
|
||||
### FOCUS ON:
|
||||
1. Core Business / Services.
|
||||
2. Unique Selling Points (USPs).
|
||||
3. Target Audience (if clear).
|
||||
4. Tech Stack or industry-specific equipment mentioned.
|
||||
5. Brand tone (e.g. "industrial", "friendly", "technical").
|
||||
|
||||
### OUTPUT:
|
||||
Return ONLY the bullet points. No intro/outro.
|
||||
`;
|
||||
const resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: `RAW_CRAWL:\n${rawCrawl.substring(0, 30000)}` }],
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
|
||||
return resp.data.choices[0].message.content;
|
||||
}
|
||||
|
||||
async function performCrawl(url: string): Promise<string> {
|
||||
const pages: { url: string, content: string, type: string }[] = [];
|
||||
const origin = new URL(url).origin;
|
||||
@@ -205,7 +243,7 @@ async function performCrawl(url: string): Promise<string> {
|
||||
return summary + pages.map(p => `--- PAGE: ${p.url} ---\n${p.content}`).join('\n\n');
|
||||
}
|
||||
|
||||
async function getAiEstimation(briefing: string, crawlContext: string, comments: string | null, apiKey: string, principles: string, techStandards: string, tone: string) {
|
||||
async function getAiEstimation(briefing: string, distilledCrawl: string, comments: string | null, apiKey: string, principles: string, techStandards: string, tone: string) {
|
||||
let usage = { prompt: 0, completion: 0, cost: 0 };
|
||||
const addUsage = (data: any) => {
|
||||
if (data?.usage) {
|
||||
@@ -222,22 +260,25 @@ async function getAiEstimation(briefing: string, crawlContext: string, comments:
|
||||
}
|
||||
};
|
||||
|
||||
// 1. PASS 1: Fact Extraction
|
||||
console.log(' ↳ Pass 1: Fact Extraction...');
|
||||
// 1. PASS 1: Fact Extraction (Briefing Sensor)
|
||||
console.log(' ↳ Pass 1: Fact Extraction (Briefing Sensor)...');
|
||||
const pass1SystemPrompt = `
|
||||
You are a precision extraction engine. Analyze the briefing and extract ONLY the raw facts.
|
||||
You are a precision sensor. Analyze the BRIEFING and extract ONLY the raw facts.
|
||||
Tone: Literal, non-interpretive.
|
||||
Output language: GERMAN (Strict).
|
||||
|
||||
### MISSION:
|
||||
Focus 100% on the BRIEFING text provided by the user. Use the DISTILLED_CRAWL only as background context for terms or company details. If there is a conflict, the BRIEFING is the absolute source of truth.
|
||||
|
||||
### OBJECTIVES:
|
||||
- Extract companyName (Strictly the name, no descriptors).
|
||||
- Extract companyAddress (Full address if found).
|
||||
- Extract personName (Primary contact if found).
|
||||
- Extract **websiteTopic**: This MUST be a single, short branch name (e.g., "Kabeltiefbau", "Logistik", "Anwaltskanzlei"). ABSOLUTELY NO SENTENCES. If the briefing says "Group-Homepage for X", extract ONLY "X".
|
||||
- Extract **websiteTopic**: This MUST be a single, short branch name (e.g., "Kabeltiefbau", "Logistik", "Anwaltskanzlei"). ABSOLUTELY NO SENTENCES.
|
||||
- Map to internal IDs for selectedPages, features, functions, apiSystems, assets.
|
||||
- Identify if isRelaunch is true (briefing mentions existing site or URL).
|
||||
- For all textual values (deadline, websiteTopic, targetAudience etc.): USE GERMAN.
|
||||
- **multilang**: ONLY if the briefing mentions multiple target languages (e.g., DE/EN). If only one language is mentioned, do NOT use multilang.
|
||||
- **multilang**: ONLY if the briefing mentions multiple target languages (e.g., DE/EN).
|
||||
- **maps**: If "Google Maps" or location maps are mentioned or implicit (Contact page).
|
||||
- **CRITICAL**: Do NOT include "social" in apiSystems unless the user explicitly wants to SYNC/POST content to social media. "Existing social media links" are NOT apiSystems.
|
||||
- **CRITICAL**: "Video Player", "Cookie Banner", "Animations" are NOT features. They are visual/base. Do NOT map them to features.
|
||||
@@ -267,7 +308,7 @@ Output language: GERMAN (Strict).
|
||||
"employeeCount": string
|
||||
}
|
||||
`;
|
||||
const pass1UserPrompt = `BRIEFING:\n${briefing}\n\nCOMMENTS:\n${comments}\n\nCRAWL:\n${crawlContext}`;
|
||||
const pass1UserPrompt = `BRIEFING (TRUTH SOURCE):\n${briefing}\n\nCOMMENTS:\n${comments}\n\nDISTILLED_CRAWL (CONTEXT ONLY):\n${distilledCrawl}`;
|
||||
const p1Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass1SystemPrompt }, { role: 'user', content: pass1UserPrompt }],
|
||||
@@ -308,8 +349,8 @@ ${JSON.stringify(facts, null, 2)}
|
||||
addUsage(p2Resp.data);
|
||||
const details = JSON.parse(p2Resp.data.choices[0].message.content);
|
||||
|
||||
// 3. PASS 3: Strategic Content
|
||||
console.log(' ↳ Pass 3: Strategic Content...');
|
||||
// 3. PASS 3: Strategic Content (Bespoke Strategy)
|
||||
console.log(' ↳ Pass 3: Strategic Content (Bespoke Strategy)...');
|
||||
const pass3SystemPrompt = `
|
||||
You are a high-end Digital Architect. Analyze the BRIEFING.
|
||||
ABSOLUTE RULE: OUTPUT MUST BE 100% GERMAN.
|
||||
@@ -319,14 +360,16 @@ ${tone}
|
||||
|
||||
### OBJECTIVE:
|
||||
1. **briefingSummary**: Summarize the project's essence for the CUSTOMER.
|
||||
- FOLLOW PRINCIPLE 1 & 5: Clear, direct, no marketing fluff, no "partnership talk".
|
||||
- Focus purely on the CUSTOMER'S goal: What are they building, why does it matter to their business, and what is the outcome?
|
||||
- FOLLOW PRINCIPLE 1 & 5: Clear, direct, no marketing fluff.
|
||||
- **MIRROR TEST**: Capture unique customer "hooks" or personality (e.g., if they mention an Instagram account or a specific hobby/preference that influences the project vibe).
|
||||
- Focus 100% on the BRIEFING (TRUTH SOURCE). Ignore the CRAWL context for this narrative.
|
||||
- Keep it 2-3 professional, direct sentences.
|
||||
2. **designVision**: A solid, grounded, and high-quality description of the look & feel.
|
||||
- FOLLOW PRINCIPLE 1 & 3: Fact-based, professional, high density of information.
|
||||
- **BESPOKE ELEMENTS**: If the client mentions specific layout ideas (e.g., "Timeline", "Grid", "Industrial Tiles"), incorporate these as strategic decisions.
|
||||
- **NO ARROGANCE**: Eliminate all "high-end", "world-class", "dominance" language. Be humble and precise.
|
||||
- **SIMPLE & CLEAR**: Use simple German. No buzzwords. "Solid Industrial Design" instead of "Technocratic Sovereignty".
|
||||
- 3-4 sentences of deep analysis.
|
||||
- 3-5 sentences of deep analysis.
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
@@ -336,7 +379,7 @@ ${tone}
|
||||
`;
|
||||
const p3Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass3SystemPrompt }, { role: 'user', content: briefing }],
|
||||
messages: [{ role: 'system', content: pass3SystemPrompt }, { role: 'user', content: `BRIEFING (TRUTH SOURCE):\n${briefing}` }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p3Resp.data);
|
||||
@@ -365,7 +408,7 @@ ${JSON.stringify({ facts, strategy }, null, 2)}
|
||||
`;
|
||||
const p4Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass4SystemPrompt }, { role: 'user', content: briefing }],
|
||||
messages: [{ role: 'system', content: pass4SystemPrompt }, { role: 'user', content: `BRIEFING (TRUTH SOURCE):\n${briefing}\n\nDISTILLED_CRAWL (CONTEXT):\n${distilledCrawl}` }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p4Resp.data);
|
||||
@@ -388,26 +431,29 @@ Each position in the quote must be perfectly justified and detailed.
|
||||
- **Schnittstellen (API)**: REAL Data Syncs (CRM, ERP). DO NOT include Tracking, Google Maps, or simple Video embedding here. Basic embedding is "Basis Website Setup".
|
||||
- **Sorglos-Betrieb (Hosting)**: Hosting & Maintenance.
|
||||
|
||||
### RULES FOR positionDescriptions:
|
||||
### RULES FOR positionDescriptions (STRICT):
|
||||
1. **ZERO GENERALIZATION**: Do NOT say "Verschiedene Funktionen".
|
||||
2. **ITEMIZED SYNTHESIS**: Mention EVERY component selected in Pass 1.
|
||||
3. **BREVITY & DENSITY**: Max 1-2 short sentences. Focus on TASKS not RESULTS.
|
||||
4. **STYLE**: Direct, engineering-grade, no fluff.
|
||||
5. **LANGUAGE**: 100% GERMAN.
|
||||
6. **SPECIFIC - PAGES**: For "Individuelle Seiten", list the pages as a comma-separated list (e.g. "Umfasst: Startseite, Über uns, Leistungen, Kontakt, Impressum").
|
||||
7. **SPECIFIC - API**: Video Uploads, Google Maps, and Tracking are NOT APIs. If video/maps are standard embedding, do NOT put them in "Schnittstellen".
|
||||
8. **SPECIFIC - HOSTING**: Always append: "Inkl. 20GB Speicher. Auto-Erweiterung +10€/10GB."
|
||||
9. **SPECIFIC - LOGIC**: Describe the ACTUAL logic.
|
||||
- BAD: "Erweiterte Formulare", "Logikfunktionen"
|
||||
- GOOD: "Anfrage-Strecken mit Validierung", "Filterung nach Kategorie", "Mehrsprachigkeits-Routing".
|
||||
2. **ITEMIZED SYNTHESIS**: Mention EVERY component selected in Pass 1.
|
||||
3. **HARD SPECIFICS**: Preserve technical details from the briefing (e.g., "110 kV", "HDD-Bohrtechnik", specific industry standards).
|
||||
4. **BREVITY & DENSITY**: Max 1-2 short sentences. Focus on TASKS not RESULTS.
|
||||
5. **STYLE**: Direct, engineering-grade, no fluff.
|
||||
6. **LANGUAGE**: 100% GERMAN.
|
||||
7. **SPECIFIC - PAGES**: For "Individuelle Seiten", list the pages as a comma-separated list.
|
||||
8. **SPECIFIC - API**: Video Uploads, Google Maps, and Tracking are NOT APIs.
|
||||
9. **SPECIFIC - HOSTING**: Always append: "Inkl. 20GB Speicher. Auto-Erweiterung +10€/10GB."
|
||||
10. **SPECIFIC - LOGIC**: Describe the ACTUAL logic. NEVER use generic terms like "Erweiterte Formulare" or "Individuelle Formular-Logik".
|
||||
11. **STRICT KEYS**: Keys MUST be EXACTLY: "Basis Website Setup", "Individuelle Seiten", "System-Module", "Logik-Funktionen", "Schnittstellen (API)", "Inhaltsverwaltung (CMS)", "Sorglos-Betrieb (Hosting)".
|
||||
|
||||
### FORBIDDEN PHRASES (STRICT BLOCKLIST):
|
||||
- "Erweiterte Formulare" (INSTEAD USE: "Komplexe Anfrage-Logik" or "Valide Formular-Systeme")
|
||||
- "Verschiedene Funktionen"
|
||||
- "Allgemeine Logik"
|
||||
- "Optimierte Darstellung"
|
||||
### EXAMPLES (FEW-SHOT):
|
||||
- **BAD**: "Individuelle Seiten für die Unternehmensdarstellung."
|
||||
- **GOOD**: "Erstellung der Seiten: Startseite (Video-Hero), Über uns (Timeline), Leistungen (110kV Montage), Kontakt."
|
||||
- **BAD**: "Anbindung von externen Systemen."
|
||||
- **GOOD**: "Native Integration von Google Maps zur Standortermittlung inkl. individueller Marker-Logik."
|
||||
|
||||
10. **NO "MARKETING LINGO"**: Never say "avoids branding" or "maximizes performance". Say "Implements HTML5 Video Player". ALWAYS DESCRIBE THE TASK.
|
||||
### FORBIDDEN PHRASES:
|
||||
- "Erweiterte Formulare", "Verschiedene Funktionen", "Allgemeine Logik", "Optimierte Darstellung", "Individuelle Formular-Logik".
|
||||
|
||||
11. **NO "MARKETING LINGO"**: Never say "avoids branding" or "maximizes performance". Say "Implements HTML5 Video Player". ALWAYS DESCRIBE THE TASK.
|
||||
|
||||
### DATA CONTEXT:
|
||||
${JSON.stringify({ facts, details, strategy, ia }, null, 2)}
|
||||
@@ -425,48 +471,60 @@ ${JSON.stringify({ facts, details, strategy, ia }, null, 2)}
|
||||
addUsage(p5Resp.data);
|
||||
const positionsData = JSON.parse(p5Resp.data.choices[0].message.content);
|
||||
|
||||
// 6. PASS 6: Reflection & Hardening
|
||||
console.log(' ↳ Pass 6: Reflection & Nuance Check...');
|
||||
// 6. PASS 6: The Industrial Critic
|
||||
console.log(' ↳ Pass 6: The Industrial Critic (Quality Gate)...');
|
||||
const pass6SystemPrompt = `
|
||||
You are a senior supervisor. Compare the CURRENT_STATE against the RAW_BRIEFING.
|
||||
Your goal is to catch missed nuances, specific customer wishes, and technical details.
|
||||
You are the "Industrial Critic". Your goal is to catch quality regressions and ensure the document is bespoke, technical, and professional.
|
||||
Analyze the CURRENT_STATE against the BRIEFING_TRUTH.
|
||||
|
||||
### CHECKLIST:
|
||||
1. **SPECIFICS**: Did we miss names, technical terms (kV, HDD, etc.), or specific vendor refs?
|
||||
2. **CONSISTENCY**: Do the positionDescriptions match the counts of features/functions in Pass 1?
|
||||
3. **DEADLINE**: Is there a specific month? (e.g. April/Mai). If yes, set "deadline" field.
|
||||
4. **LANGUAGE**: ABSOLUTE RULE: EVERYTHING MUST BE GERMAN.
|
||||
5. **CONFLICT CHECK**: If 'languagesList' has only 1 item, REMOVE 'multilang' from 'functions'.
|
||||
6. Refactor 'dontKnows' into a 'gridDontKnows' object for missing technical facts.
|
||||
### CRITICAL ERROR CHECKLIST (FAIL IF FOUND):
|
||||
1. **Placeholder Leakage**: Catch "null", "undefined", or generic strings like "Verschiedene Funktionen", "Erweiterte Formulare", "Individuelle Formular-Logik".
|
||||
2. **Detail Loss**: The user mentioned specific terms (e.g., "110 kV", "HDD", "Timeline", "Instagram"). Are they present in the positionDescriptions or sitemap? If not, ADD THEM.
|
||||
3. **Consistency**: Ensure the count of pages in "Individuelle Seiten" matches the sitemap pages.
|
||||
4. **Deadlines**: Ensure relative dates (e.g., "April / Mai") are resolved to the year 2026.
|
||||
5. **Tone Drift**: Remove any marketing "fluff" or "sales-y" language. Maintain the "Industrial Design" persona.
|
||||
|
||||
### CURRENT_STATE:
|
||||
### MISSION:
|
||||
Return updated fields ONLY. Specifically focus on hardening 'positionDescriptions', 'sitemap', and 'briefingSummary'.
|
||||
|
||||
### DATA CONTEXT:
|
||||
${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
|
||||
`;
|
||||
const p6Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass6SystemPrompt }, { role: 'user', content: `RAW_BRIEFING:\n${briefing}\n\nEnhance the state. Return ONLY the delta or the corrected fields.` }],
|
||||
messages: [{ role: 'system', content: pass6SystemPrompt }, { role: 'user', content: `BRIEFING_TRUTH:\n${briefing}` }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p6Resp.data);
|
||||
const reflection = JSON.parse(p6Resp.data.choices[0].message.content);
|
||||
|
||||
let finalState = {
|
||||
// 6. Reflection Merge Utility
|
||||
const mergeReflection = (state: any, reflection: any) => {
|
||||
let result = { ...state };
|
||||
const unwrap = (obj: any): any => {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
||||
if (obj["0"]) return unwrap(obj["0"]);
|
||||
if (obj.state) return unwrap(obj.state);
|
||||
if (obj.facts) return unwrap(obj.facts);
|
||||
if (obj.strategy) return unwrap(obj.strategy);
|
||||
if (obj.ia) return unwrap(obj.ia);
|
||||
if (obj.positionsData) return unwrap(obj.positionsData);
|
||||
return obj;
|
||||
};
|
||||
|
||||
const cleanedReflection = unwrap(reflection);
|
||||
return { ...result, ...cleanedReflection };
|
||||
};
|
||||
|
||||
let finalState = mergeReflection({
|
||||
...initialState,
|
||||
...facts,
|
||||
...strategy,
|
||||
...ia,
|
||||
...positionsData,
|
||||
...reflection,
|
||||
statusQuo: facts.isRelaunch ? 'Relaunch' : 'Neuentwicklung'
|
||||
};
|
||||
...positionsData
|
||||
}, reflection);
|
||||
|
||||
// Flatten if AI nested everything under "0", "state" or "state.0"
|
||||
if (finalState["0"]) finalState = { ...finalState, ...finalState["0"] };
|
||||
if ((finalState as any).state) {
|
||||
const nestedState = (finalState as any).state;
|
||||
finalState = { ...finalState, ...nestedState };
|
||||
if (nestedState["0"]) finalState = { ...finalState, ...nestedState["0"] };
|
||||
}
|
||||
finalState.statusQuo = facts.isRelaunch ? 'Relaunch' : 'Neuentwicklung';
|
||||
|
||||
// Normalization Layer: Map hallucinated German keys back to internal keys
|
||||
const normalizationMap: Record<string, string> = {
|
||||
|
||||
@@ -15,8 +15,8 @@ const __dirname = path.dirname(__filename);
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isInteractive = args.includes('--interactive') || args.includes('-I');
|
||||
const isEstimationOnly = args.includes('--estimation') || args.includes('-E');
|
||||
const inputPath = args.find((_, i) => args[i - 1] === '--input' || args[i - 1] === '-i');
|
||||
const outputPath = args.find((_, i) => args[i - 1] === '--output' || args[i - 1] === '-o');
|
||||
|
||||
let state = { ...initialState };
|
||||
|
||||
@@ -39,7 +39,7 @@ async function main() {
|
||||
const monthlyPrice = calculateMonthly(state);
|
||||
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
|
||||
|
||||
const finalOutputPath = outputPath || generateDefaultPath(state);
|
||||
const finalOutputPath = generateDefaultPath(state);
|
||||
const outputDir = path.dirname(finalOutputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
@@ -66,7 +66,9 @@ async function main() {
|
||||
createElement(CombinedQuotePDF as any, {
|
||||
estimationProps,
|
||||
techDetails: getTechDetails(),
|
||||
principles: getPrinciples()
|
||||
principles: getPrinciples(),
|
||||
mode: isEstimationOnly ? 'estimation' : 'full',
|
||||
showAgbs: !isEstimationOnly // AGBS only for full quotes
|
||||
}) as any,
|
||||
finalOutputPath
|
||||
);
|
||||
@@ -144,8 +146,10 @@ function generateDefaultPath(state: any) {
|
||||
const now = new Date();
|
||||
const month = now.toISOString().slice(0, 7);
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
// Add seconds and minutes for 100% unique names without collision
|
||||
const time = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '-');
|
||||
const company = (state.companyName || state.name || 'Unknown').replace(/[^a-z0-9]/gi, '_');
|
||||
return path.join(process.cwd(), 'out', 'estimations', month, `${day}_${company}_${state.projectType}.pdf`);
|
||||
return path.join(process.cwd(), 'out', 'estimations', month, `${day}_${time}_${company}_${state.projectType}.pdf`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user