Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 217858ce83 | |||
| 8b7a364f98 | |||
| f315ddabc6 | |||
| 497d54de3c | |||
| abb4c740e6 |
@@ -2,7 +2,7 @@ name: Build and Push Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,6 +12,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history so `git rev-list --count HEAD` yields the true commit count
|
||||||
|
# driving both the app version (v1.NNN) and the org.alwisp.version label.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -22,14 +26,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
run: |
|
run: |
|
||||||
# gitea.repository is already "owner/repo" (e.g. jason/breedr).
|
|
||||||
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||||
docker build -t "${IMAGE}:latest" .
|
GIT_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
COMMIT_COUNT="$(git rev-list --count HEAD)"
|
||||||
|
docker build \
|
||||||
|
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||||
|
--build-arg BUILD_TIME="${BUILD_TIME}" \
|
||||||
|
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
|
||||||
|
--label org.alwisp.git-sha="${{ gitea.sha }}" \
|
||||||
|
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
|
||||||
|
--label org.alwisp.repo="${{ gitea.repository }}" \
|
||||||
|
-t "${IMAGE}:latest" .
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
# Dangling-only prune: removes untagged leftovers from previous builds.
|
# Dangling-only prune: removes untagged leftovers from previous builds. Never
|
||||||
# Never removes the tagged :latest (kept for the Unraid deployment) or
|
# removes the tagged :latest or any image referenced by a running container.
|
||||||
# any image referenced by a container.
|
|
||||||
- name: Prune dangling images on host
|
- name: Prune dangling images on host
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image prune -f 2>/dev/null || true
|
run: docker image prune -f 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Trigger PORT redeploy
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
NAME="${{ gitea.repository }}"; NAME="${NAME##*/}"
|
||||||
|
curl -fsS -X POST https://port.alwisp.com/hooks/gitea \
|
||||||
|
-H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"container\":\"${NAME}\"}" || echo "PORT redeploy trigger failed (non-fatal)"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
+53
-18
@@ -1,46 +1,81 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Message Point Media brand palette.
|
||||||
|
* Gold system (Middle / Light / Dark Gold) on a Dark Shade charcoal ground,
|
||||||
|
* with Light Shade text. Montserrat headings, Open Sans body.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #0f172a;
|
/* Brand core */
|
||||||
--bg-card: rgba(30, 41, 59, 0.7);
|
--mpm-gold-mid: #dcbb4f; /* Middle Gold — default accent */
|
||||||
--accent-primary: #38bdf8;
|
--mpm-gold-light: #f5cd15; /* Light Gold — accents on dark backgrounds */
|
||||||
--accent-secondary: #818cf8;
|
--mpm-gold-dark: #998643; /* Dark Gold — titles on light backgrounds */
|
||||||
--text-main: #f1f5f9;
|
--mpm-shade-dark: #232022; /* Dark Shade — near-black ground */
|
||||||
--text-dim: #94a3b8;
|
--mpm-shade-light: #f5f1ec;/* Light Shade — off-white text */
|
||||||
--glass-border: rgba(255, 255, 255, 0.1);
|
--mpm-accent: #849698; /* Light Accent — steel gray, secondary text */
|
||||||
--glass-highlight: rgba(255, 255, 255, 0.05);
|
|
||||||
|
/* Semantic tokens used across the app */
|
||||||
|
--bg-dark: var(--mpm-shade-dark);
|
||||||
|
--bg-card: rgba(40, 37, 39, 0.72);
|
||||||
|
--accent-primary: var(--mpm-gold-mid);
|
||||||
|
--accent-secondary: var(--mpm-gold-light);
|
||||||
|
--text-main: var(--mpm-shade-light);
|
||||||
|
--text-dim: var(--mpm-accent);
|
||||||
|
--glass-border: rgba(220, 187, 79, 0.18);
|
||||||
|
--glass-highlight: rgba(220, 187, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expose brand tokens as Tailwind v4 theme colors so utilities like
|
||||||
|
text-accent-primary / text-text-dim / from-accent-primary resolve. */
|
||||||
|
@theme inline {
|
||||||
|
--color-bg-dark: var(--bg-dark);
|
||||||
|
--color-accent-primary: var(--accent-primary);
|
||||||
|
--color-accent-secondary: var(--accent-secondary);
|
||||||
|
--color-text-main: var(--text-main);
|
||||||
|
--color-text-dim: var(--text-dim);
|
||||||
|
--color-gold-dark: var(--mpm-gold-dark);
|
||||||
|
--font-sans: var(--font-open-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-heading: var(--font-montserrat), var(--font-open-sans), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: radial-gradient(circle at top left, #1e293b, #0f172a);
|
background: radial-gradient(circle at top left, #2f2b2d, var(--mpm-shade-dark));
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-open-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Montserrat carries all headings — weight, not just size, sets hierarchy. */
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: var(--font-montserrat), var(--font-open-sans), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.45);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-input {
|
.glass-input {
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background: rgba(35, 32, 34, 0.5);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
color: white;
|
color: var(--text-main);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-input:focus {
|
.glass-input:focus {
|
||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
box-shadow: 0 0 0 2px rgba(220, 187, 79, 0.25);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gold gradient echoes the MPM logo mark (light gold → dark gold).
|
||||||
|
Dark Shade text keeps the label readable on the gold fill. */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
background: linear-gradient(135deg, var(--mpm-gold-light), var(--mpm-gold-dark));
|
||||||
color: white;
|
color: var(--mpm-shade-dark);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
@@ -49,20 +84,20 @@ body {
|
|||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
opacity: 0.9;
|
opacity: 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--glass-highlight);
|
background: var(--glass-highlight);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
color: white;
|
color: var(--text-main);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(220, 187, 79, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
|||||||
+15
-5
@@ -1,13 +1,23 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Montserrat, Open_Sans } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/components/providers/AuthProvider";
|
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
// MPM brand typography: Montserrat for headings, Open Sans for body.
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["500", "600", "700", "800"],
|
||||||
|
variable: "--font-montserrat",
|
||||||
|
});
|
||||||
|
|
||||||
|
const openSans = Open_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-open-sans",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "WFH Daily Report",
|
title: "WFH Daily Report | Message Point Media",
|
||||||
description: "Sleek and modern work from home reporting tool",
|
description: "Daily work-from-home reporting for Message Point Media. Born to Innovate. Built to Last.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -17,7 +27,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className={`${montserrat.variable} ${openSans.variable}`}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -193,13 +193,16 @@ export default function ReportForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||||
<div className="glass-card p-8 max-w-md w-full text-center space-y-6 animate-fade-in">
|
<div className="glass-card p-8 max-w-md w-full text-center space-y-6 animate-fade-in">
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-primary to-accent-secondary bg-clip-text text-transparent">
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
WFH Tracker
|
<img src="/mpm-logo.png" alt="Message Point Media" className="h-12 mx-auto" />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-secondary to-gold-dark bg-clip-text text-transparent">
|
||||||
|
WFH Daily Report
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-dim">Please sign in with your company Google account to access your daily reports.</p>
|
<p className="text-text-dim">Please sign in with your company Google account to access your daily reports.</p>
|
||||||
<button onClick={() => signIn("google")} className="btn-primary w-full flex items-center justify-center gap-2">
|
<button onClick={() => signIn("google")} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
<p className="text-xs text-text-dim tracking-wide pt-2">Born to Innovate. Built to Last.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -208,12 +211,16 @@ export default function ReportForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-8 animate-fade-in">
|
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-8 animate-fade-in">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/mpm-logo.png" alt="Message Point Media" className="h-10 hidden sm:block" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{view === "REPORT" ? "Daily Report" : "Administration"}</h1>
|
<h1 className="text-3xl font-bold">{view === "REPORT" ? "Daily Report" : "Administration"}</h1>
|
||||||
<p className="text-text-dim flex items-center gap-2">
|
<p className="text-text-dim flex items-center gap-2">
|
||||||
<Calendar size={16} /> {new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
<Calendar size={16} /> {new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{session.user.role === "ADMIN" && (
|
{session.user.role === "ADMIN" && (
|
||||||
<button
|
<button
|
||||||
@@ -337,9 +344,9 @@ export default function ReportForm() {
|
|||||||
onChange={(e) => updateTask(task.id, { status: e.target.value as TaskStatus, type: 'COMPLETED' })}
|
onChange={(e) => updateTask(task.id, { status: e.target.value as TaskStatus, type: 'COMPLETED' })}
|
||||||
className="bg-transparent text-sm text-text-dim border-none p-0 focus:ring-0 outline-none appearance-none cursor-pointer"
|
className="bg-transparent text-sm text-text-dim border-none p-0 focus:ring-0 outline-none appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="DONE" className="bg-slate-800">Completed</option>
|
<option value="DONE" className="bg-bg-dark">Completed</option>
|
||||||
<option value="IN_PROGRESS" className="bg-slate-800">In Progress</option>
|
<option value="IN_PROGRESS" className="bg-bg-dark">In Progress</option>
|
||||||
<option value="CANCELLED" className="bg-slate-800">Delayed</option>
|
<option value="CANCELLED" className="bg-bg-dark">Delayed</option>
|
||||||
</select>
|
</select>
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<LinkIcon size={14} className="absolute left-0 top-1/2 -translate-y-1/2 text-text-dim" />
|
<LinkIcon size={14} className="absolute left-0 top-1/2 -translate-y-1/2 text-text-dim" />
|
||||||
|
|||||||
+97
-26
@@ -1,8 +1,28 @@
|
|||||||
import { google, Auth } from 'googleapis';
|
import { google, Auth } from 'googleapis';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
// The MPM logo is embedded directly into the report HTML as a base64 data URI
|
||||||
|
// so it renders regardless of network reachability (e.g. a LAN-only deployment
|
||||||
|
// where Google's importer can't fetch an external URL). Read from disk once and
|
||||||
|
// cache; `null` means the asset was missing and the report renders without it.
|
||||||
|
let cachedLogoDataUri: string | null | undefined;
|
||||||
|
function getLogoDataUri(): string | null {
|
||||||
|
if (cachedLogoDataUri !== undefined) return cachedLogoDataUri;
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(process.cwd(), 'public', 'mpm-logo-doc.png');
|
||||||
|
const b64 = readFileSync(logoPath).toString('base64');
|
||||||
|
cachedLogoDataUri = `data:image/png;base64,${b64}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MPM report logo could not be loaded; rendering without it:', error);
|
||||||
|
cachedLogoDataUri = null;
|
||||||
|
}
|
||||||
|
return cachedLogoDataUri;
|
||||||
|
}
|
||||||
|
|
||||||
type ReportWithRelations = Prisma.ReportGetPayload<{
|
type ReportWithRelations = Prisma.ReportGetPayload<{
|
||||||
include: { tasks: true; user: true };
|
include: { tasks: true; user: true };
|
||||||
}>;
|
}>;
|
||||||
@@ -127,62 +147,113 @@ export function generateReportHTML(report: ReportWithRelations) {
|
|||||||
const plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED');
|
const plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED');
|
||||||
const completedTasks = report.tasks.filter((t) => t.type === 'COMPLETED');
|
const completedTasks = report.tasks.filter((t) => t.type === 'COMPLETED');
|
||||||
|
|
||||||
const cellStyle = "padding: 10px; border-bottom: 1px solid #e2e8f0; font-family: Arial, sans-serif; font-size: 11pt;";
|
// MPM brand palette (matches the hex values used across MPM technical docs)
|
||||||
const headerStyle = "padding: 12px 10px; background-color: #f1f5f9; border-bottom: 2px solid #cbd5e1; font-family: Arial, sans-serif; font-size: 11pt; font-weight: bold; text-align: left; color: #334155;";
|
const GOLD_DARK = '#998643'; // document titles, section headings
|
||||||
|
const GOLD_MID = '#DCBB4F'; // accent rules / dividers
|
||||||
|
const SHADE_DARK = '#232022'; // body text / table header background
|
||||||
|
const SHADE_LIGHT = '#F5F1EC'; // key-column / table header text fill
|
||||||
|
const OFF_WHITE = '#FAF7F2'; // alternating data-table rows
|
||||||
|
const ACCENT = '#849698'; // secondary / muted text
|
||||||
|
const BORDER = '#E7E0D5'; // hairline cell borders (warm gray)
|
||||||
|
|
||||||
|
const bodyFont = "'Open Sans', Arial, sans-serif";
|
||||||
|
const headingFont = "'Montserrat', 'Open Sans', Arial, sans-serif";
|
||||||
|
|
||||||
|
const statusLabel = (report.status || 'DRAFT').charAt(0) + (report.status || 'DRAFT').slice(1).toLowerCase();
|
||||||
|
const docRef = `WFH-${new Date(report.date).toISOString().slice(0, 10)}`;
|
||||||
|
|
||||||
|
// Data-table cell styles
|
||||||
|
const cellStyle = `padding: 8px 12px; border: 1px solid ${BORDER}; font-family: ${bodyFont}; font-size: 10.5pt; color: ${SHADE_DARK}; vertical-align: top;`;
|
||||||
|
const headerStyle = `padding: 9px 12px; background-color: ${SHADE_DARK}; border: 1px solid ${SHADE_DARK}; font-family: ${headingFont}; font-size: 10pt; font-weight: 600; text-align: left; color: ${SHADE_LIGHT};`;
|
||||||
|
const rowBg = (i: number) => (i % 2 === 1 ? ` background-color: ${OFF_WHITE};` : '');
|
||||||
|
|
||||||
|
// Document Control-style key/value row
|
||||||
|
const metaKey = `width: 26%; padding: 8px 12px; background-color: ${SHADE_LIGHT}; border: 1px solid ${BORDER}; font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK};`;
|
||||||
|
const metaVal = `padding: 8px 12px; border: 1px solid ${BORDER}; background-color: #FFFFFF; font-size: 10.5pt; color: ${SHADE_DARK};`;
|
||||||
|
const metaRow = (k: string, v: string) =>
|
||||||
|
`<tr><td style="${metaKey}">${k}</td><td style="${metaVal}">${v}</td></tr>`;
|
||||||
|
|
||||||
|
// Section label styled like the technical docs' "Document Control" heading.
|
||||||
|
const sectionLabel = (text: string) =>
|
||||||
|
`<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10.5pt; letter-spacing: 1.5px; text-transform: uppercase; color: ${GOLD_DARK}; margin: 0 0 8px 0;">${text}</p>`;
|
||||||
|
const sectionHeading = (text: string) =>
|
||||||
|
`<h2 style="font-family: ${headingFont}; font-weight: 600; font-size: 14pt; color: ${GOLD_DARK}; margin: 26px 0 10px 0;">${text}</h2>`;
|
||||||
|
|
||||||
|
// A gold rule rendered as a filled 1-row table (survives Google Docs conversion,
|
||||||
|
// where border-bottom on headings/divs does not).
|
||||||
|
const goldRule = `<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 12px 0 22px 0;"><tr><td style="height: 3px; line-height: 3px; font-size: 0; padding: 0; background-color: ${GOLD_MID};"> </td></tr></table>`;
|
||||||
|
|
||||||
|
const emptyNote = (text: string) =>
|
||||||
|
`<p style="font-style: italic; color: ${ACCENT}; font-family: ${bodyFont}; margin: 0 0 8px 0;">${text}</p>`;
|
||||||
|
|
||||||
|
// Embed the logo inline so it survives conversion without a reachable host.
|
||||||
|
const logoDataUri = getLogoDataUri();
|
||||||
|
const logoHtml = logoDataUri
|
||||||
|
? `<img src="${logoDataUri}" alt="Message Point Media" style="height: 40px; margin-bottom: 14px;" />`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; color: #334155; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px;">
|
<body style="font-family: ${bodyFont}; color: ${SHADE_DARK}; line-height: 1.5; max-width: 760px; margin: 0 auto; padding: 16px 24px;">
|
||||||
<h1 style="color: #0f172a; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; font-family: Arial, sans-serif; margin-bottom: 20px;">WFH Daily Report</h1>
|
${logoHtml}
|
||||||
|
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9pt; letter-spacing: 2px; text-transform: uppercase; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Message Point Media</p>
|
||||||
|
<h1 style="font-family: ${headingFont}; font-weight: 700; font-size: 23pt; color: ${GOLD_DARK}; margin: 0 0 4px 0;">WFH Daily Report</h1>
|
||||||
|
<p style="font-size: 10.5pt; color: ${ACCENT}; margin: 0;">Daily Work-From-Home Status | ${escapeHtml(report.user.name)} | ${dateStr}</p>
|
||||||
|
${goldRule}
|
||||||
|
|
||||||
<div style="background-color: #f8fafc; padding: 20px; border-left: 4px solid #3b82f6; border-radius: 4px; margin-bottom: 30px; font-family: Arial, sans-serif;">
|
${sectionLabel('Report Details')}
|
||||||
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Date:</strong> ${dateStr}</p>
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 4px;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${escapeHtml(report.user.name)}</p>
|
${metaRow('Employee', escapeHtml(report.user.name))}
|
||||||
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${escapeHtml(report.managerName || 'N/A')}</p>
|
${metaRow('Date', dateStr)}
|
||||||
</div>
|
${metaRow('Manager', escapeHtml(report.managerName || 'N/A'))}
|
||||||
|
${metaRow('Status', statusLabel)}
|
||||||
|
${metaRow('Reference', docRef)}
|
||||||
|
</table>
|
||||||
|
|
||||||
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Planned Tasks</h2>
|
${sectionHeading('1. Planned Tasks')}
|
||||||
${plannedTasks.length > 0 ? `
|
${plannedTasks.length > 0 ? `
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="${headerStyle} width: 45%;">Description</th>
|
<th style="${headerStyle} width: 45%;">Description</th>
|
||||||
<th style="${headerStyle} width: 20%;">Estimate</th>
|
<th style="${headerStyle} width: 20%;">Estimate</th>
|
||||||
<th style="${headerStyle} width: 35%;">Notes</th>
|
<th style="${headerStyle} width: 35%;">Notes</th>
|
||||||
</tr>
|
</tr>
|
||||||
${plannedTasks.map((t) => `
|
${plannedTasks.map((t, i) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="${cellStyle}">${escapeHtml(t.description)}</td>
|
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
|
||||||
<td style="${cellStyle} color: #64748b;">${escapeHtml(t.timeEstimate || '-')}</td>
|
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.timeEstimate || '-')}</td>
|
||||||
<td style="${cellStyle} color: #64748b;">${escapeHtml(t.notes || '-')}</td>
|
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.notes || '-')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</table>
|
</table>
|
||||||
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No planned tasks for today.</p>`}
|
` : emptyNote('No planned tasks for today.')}
|
||||||
|
|
||||||
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Completed Tasks</h2>
|
${sectionHeading('2. Completed Tasks')}
|
||||||
${completedTasks.length > 0 ? `
|
${completedTasks.length > 0 ? `
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="${headerStyle} width: 40%;">Description</th>
|
<th style="${headerStyle} width: 40%;">Description</th>
|
||||||
<th style="${headerStyle} width: 20%;">Status</th>
|
<th style="${headerStyle} width: 20%;">Status</th>
|
||||||
<th style="${headerStyle} width: 40%;">Work Link</th>
|
<th style="${headerStyle} width: 40%;">Work Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
${completedTasks.map((t) => {
|
${completedTasks.map((t, i) => {
|
||||||
const link = safeLink(t.link);
|
const link = safeLink(t.link);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="${cellStyle}">${escapeHtml(t.description)}</td>
|
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
|
||||||
<td style="${cellStyle} font-weight: bold; color: #059669;">${escapeHtml(t.status || 'Done')}</td>
|
<td style="${cellStyle}${rowBg(i)} font-weight: 600; color: ${GOLD_DARK};">${escapeHtml(t.status || 'Done')}</td>
|
||||||
<td style="${cellStyle}">${link ? `<a href="${escapeHtml(link)}" style="color: #2563eb; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
|
<td style="${cellStyle}${rowBg(i)}">${link ? `<a href="${escapeHtml(link)}" style="color: ${GOLD_DARK}; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</table>
|
</table>
|
||||||
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No completed tasks reported today.</p>`}
|
` : emptyNote('No completed tasks reported today.')}
|
||||||
|
|
||||||
<div style="margin-top: 50px; font-size: 9pt; color: #cbd5e1; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 20px; font-family: Arial, sans-serif;">
|
${goldRule}
|
||||||
Generated automatically by WFH App
|
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Prepared by ${escapeHtml(report.user.name)}</p>
|
||||||
</div>
|
<p style="font-size: 9pt; color: ${ACCENT}; margin: 0 0 10px 0;">Message Point Media · ${docRef} · Generated by the WFH Daily Report app</p>
|
||||||
|
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9.5pt; letter-spacing: 0.5px; color: ${GOLD_DARK}; margin: 0 0 2px 0;">Born to Innovate. Built to Last.</p>
|
||||||
|
<p style="font-size: 8.5pt; font-style: italic; color: ${ACCENT}; margin: 0;">Compelling… Affordable… Dynamic… Messaging… It’s What We Do!</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user