Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/TROUBLESHOOTING.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,7 @@ Common issues and their solutions. Add new entries as problems are discovered an
| Azure managed SSL certificate fails with "A record must point to..."
| Azure App Service managed certificates require a proper A record for root domains, not a CNAME or ALIAS. Configure DNS with an A record for `@` pointing to the App Service IP shown in the error message. CNAME records are technically not allowed on root domains by DNS standards. The `www` subdomain can use a CNAME pointing to your App Service hostname.

| Hours and days display incorrectly (e.g., 12h shows as 12d instead of 1.5d)
| Resources with Base Unit of Measure = DAY need an HOUR unit configured for conversion. In BC: (1) Open the Resource card (2) Go to Related → Resource → Units of Measure (3) The DAY row must stay at Qty per Unit of Measure = 1 (base unit, cannot change) (4) Add a new row for HOUR with Qty per Unit of Measure = 0.125 (for 8-hour days) or 0.133 (for 7.5-hour days). This tells BC that 1 HOUR = 0.125 DAYs. Thyme reads these conversion factors from the `/resourceUnitsOfMeasure` API (requires BC Extension v1.7.0+).

|===
27 changes: 26 additions & 1 deletion docs/bc-terminology.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ Provides budget and billable planning data (the "quote").
| `Resource`, `Item`, or `G/L Account`

| quantity
| Planned quantity (hours for Resources)
| Planned quantity (in unit specified by `unitOfMeasureCode`)

| unitOfMeasureCode
| Unit of measure (e.g., `HOUR`, `DAY`) - requires v1.7.0+
Comment thread
BenGWeeks marked this conversation as resolved.
Outdated

| unitCost
| Cost per unit (internal)
Expand All @@ -247,6 +250,22 @@ Provides budget and billable planning data (the "quote").
| quantity × unitPrice (customer)
|===

==== Unit of Measure Conversion

Job Planning Lines may specify quantities in different units (e.g., HOUR or DAY).
Thyme automatically converts to hours using the Resource Unit of Measure conversion factor.

For example, if a planning line specifies:

* quantity: 1.5
* unitOfMeasureCode: DAY

And the resource has DAY configured with `qtyPerUnitOfMeasure: 8`, Thyme displays:

* Hours Planned: 12.0h (1.5 × 8)
Comment thread
BenGWeeks marked this conversation as resolved.
Outdated

This conversion is fetched from the `/resourceUnitsOfMeasure` endpoint (requires v1.7.0+).

Comment thread
BenGWeeks marked this conversation as resolved.
Outdated
=== Time Entries (`/timeEntries`)

Provides posted/invoiced data from Job Ledger Entry.
Expand Down Expand Up @@ -349,6 +368,12 @@ The PDF export creates a customer-friendly report that respects the visibility t
| v1.6.0+

| Time Entries API (Job Ledger)
| v1.6.0+

| Unit of Measure Code (on Job Planning Lines)
| v1.7.0+

| Resource Units of Measure API (conversion factors)
| v1.7.0+
|===

Expand Down
117 changes: 117 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,120 @@ body {
background-color: rgba(0, 210, 106, 0.3);
color: white;
}

/* Print styles for PDF export */
@media print {
/* Page setup - minimal margins for full background coverage */
@page {
size: A4;
margin: 0;
}

/* Preserve exact colors for PDF (not physical print) */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}

/* Scale down text slightly for print */
html {
font-size: 10px !important;
}

/* Full page background coverage */
html,
body {
height: 100% !important;
min-height: 100% !important;
background-color: #020617 !important;
}

body {
padding: 0.5cm !important;
padding-bottom: 1cm !important;
}

/* Hide the fixed grid pattern in print */
.fixed.inset-0 {
display: none !important;
}

/* Make layout not push footer to bottom - target the outer wrapper */
.bg-dark-950.min-h-screen {
min-height: auto !important;
height: auto !important;
}

/* Disable animations during print */
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}

/* Hide nav links but show header with logo */
header nav {
display: none !important;
}

/* Footer - transparent background, just text */
footer {
background-color: transparent !important;
border-top: none !important;
}

/* Spacing reduction for one-page fit */
.space-y-6 > * + * {
margin-top: 0.75rem !important;
}

.space-y-4 > * + * {
margin-top: 0.5rem !important;
}

/* Preserve original grid gaps (gap-4 = 16px) */
.gap-4 {
gap: 16px !important;
Comment thread
BenGWeeks marked this conversation as resolved.
}

/* Smaller padding in cards */
.p-4 {
padding: 0.4rem !important;
}

.p-6 {
padding: 0.6rem !important;
}

/* Reduce main content padding, but keep bottom margin for spacing before footer */
main {
padding-top: 0.5rem !important;
padding-bottom: 0.5cm !important;
}

/* Prevent page breaks inside cards */
[class*='rounded-lg'] {
break-inside: avoid;
}

/* Ensure charts/tables don't get split */
table {
break-inside: avoid;
}

svg {
break-inside: avoid;
}

/* Compact table rows */
tr {
line-height: 1.2 !important;
}

td,
th {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
}
}
12 changes: 6 additions & 6 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ export function Header() {
</div>
</Link>

{/* Navigation - only shown when authenticated */}
{/* Navigation - only shown when authenticated, hidden in print */}
{isAuthenticated && (
<nav className="hidden sm:ml-8 sm:flex sm:space-x-1">
<nav className="hidden sm:ml-8 sm:flex sm:space-x-1 print:!hidden">
{navigation.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
Expand All @@ -120,8 +120,8 @@ export function Header() {
)}
</div>

{/* Right side */}
<div className="flex items-center gap-2">
{/* Right side - hidden in print */}
<div className="flex items-center gap-2 print:hidden">
{isAuthenticated ? (
<>
{/* Settings icon */}
Expand Down Expand Up @@ -232,9 +232,9 @@ export function Header() {
</div>
</div>

{/* Mobile Navigation - only for authenticated users */}
{/* Mobile Navigation - only for authenticated users, hidden in print */}
{isAuthenticated && (
<nav className="border-dark-700 border-t sm:hidden">
<nav className="border-dark-700 border-t sm:hidden print:!hidden">
<div className="flex justify-around">
{navigation.map((item) => {
const Icon = item.icon;
Expand Down
16 changes: 14 additions & 2 deletions src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,27 @@ export function Layout({ children }: LayoutProps) {
</main>
<footer className="border-dark-800 bg-dark-900/50 border-t">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<p className="text-dark-500 text-center text-sm">
{/* Screen version */}
<p className="text-dark-500 text-center text-sm print:hidden">
Thyme v{appVersion} - Time Tracking for Business Central by{' '}
<a
href="https://knowall.ai"
target="_blank"
rel="noopener noreferrer"
className="text-knowall-green hover:text-knowall-green-light transition-colors"
>
KnowAll.ai
KnowAll AI
</a>
</p>
{/* Print version */}
<p className="text-dark-500 hidden text-center text-sm print:block">
Want to get Thyme for Business Central? Go to{' '}
<a href="https://getthyme.ai" className="text-knowall-green">
www.GetThyme.ai
</a>
. Built by{' '}
<a href="https://knowall.ai" className="text-knowall-green">
KnowAll AI
</a>
</p>
</div>
Expand Down
52 changes: 38 additions & 14 deletions src/components/plan/PlanEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { Modal, Button } from '@/components/ui';
import { useCompanyStore } from '@/hooks';
import { bcClient } from '@/services/bc/bcClient';
import { getBCResourceUrl, getBCJobUrl } from '@/utils/bcUrls';
import {
buildUOMConversionMap,
convertToHours,
convertFromHours,
type UOMConversionMap,
} from '@/utils';
import type { AllocationBlock } from '@/hooks/usePlanStore';
import { format, parseISO, eachDayOfInterval, startOfWeek, endOfWeek, getWeek } from 'date-fns';

Expand Down Expand Up @@ -57,19 +63,30 @@ export function PlanEditModal({
[weekStart, weekEnd]
);

// UOM conversion map for converting between hours and resource base units
const [uomMap, setUomMap] = useState<UOMConversionMap>(new Map());

// Fetch all existing planning lines for this resource/project/task/week
const fetchExistingLines = useCallback(async () => {
if (!allocation) return;

setIsLoadingExisting(true);
try {
const existingLines = await bcClient.getJobPlanningLinesForWeek({
jobNo: allocation.projectNumber,
jobTaskNo: allocation.taskNumber || '',
resourceNo: allocation.resourceNumber,
weekStart: format(weekStart, 'yyyy-MM-dd'),
weekEnd: format(weekEnd, 'yyyy-MM-dd'),
});
// Fetch UOM conversion factors and planning lines in parallel
const [existingLines, resourceUOMs] = await Promise.all([
bcClient.getJobPlanningLinesForWeek({
jobNo: allocation.projectNumber,
jobTaskNo: allocation.taskNumber || '',
resourceNo: allocation.resourceNumber,
weekStart: format(weekStart, 'yyyy-MM-dd'),
weekEnd: format(weekEnd, 'yyyy-MM-dd'),
}),
bcClient.getResourceUnitsOfMeasure(),
]);

// Build UOM conversion map for unit conversions
const conversionMap = buildUOMConversionMap(resourceUOMs);
setUomMap(conversionMap);

// Group lines by date and aggregate hours for display
const byDate: Record<string, ExistingLine[]> = {};
Expand All @@ -87,10 +104,14 @@ export function PlanEditModal({
});
}

// Sum hours for each date for display
// Sum and convert to hours for each date
for (const [date, lines] of Object.entries(byDate)) {
const total = lines.reduce((sum, l) => sum + l.quantity, 0);
hours[date] = total.toString();
const totalQuantity = lines.reduce((sum, l) => sum + l.quantity, 0);
// Convert base unit to hours using shared conversion utility
const totalHours = convertToHours(allocation.resourceNumber, totalQuantity, conversionMap);
// Round to nearest 0.5 hour
const rounded = Math.round(totalHours * 2) / 2;
hours[date] = rounded.toString();
}

setExistingLinesByDate(byDate);
Expand Down Expand Up @@ -237,26 +258,29 @@ export function PlanEditModal({
deleted++;
}

// Update existing lines with new quantity
// Update existing lines with new quantity (convert hours to resource's base unit)
for (const item of toUpdate) {
const quantityInBaseUnit = convertFromHours(allocation.resourceNumber, item.hours, uomMap);
await bcClient.updateJobPlanningLine(
item.id,
Comment thread
BenGWeeks marked this conversation as resolved.
{
quantity: item.hours,
quantity: quantityInBaseUnit,
},
item.etag
);
updated++;
}

// Create new lines
// Create new lines (convert hours to resource's base unit - typically DAY)
for (const item of toCreate) {
const quantityInBaseUnit = convertFromHours(allocation.resourceNumber, item.hours, uomMap);
await bcClient.createJobPlanningLine({
jobNo: allocation.projectNumber,
jobTaskNo: allocation.taskNumber || '',
resourceNo: allocation.resourceNumber,
planningDate: item.date,
quantity: item.hours,
quantity: quantityInBaseUnit,
// Don't specify unitOfMeasureCode - let BC use the resource's default
});
created++;
}
Expand Down
Loading
Loading