Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's Project Overview 'projects' flag, both registered in the same files) as additive unions — accounting + incomingInvoices + expenses AND projects all coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no collisions. Frontend build + backend node --check pass.
This commit is contained in:
@@ -1,60 +1,39 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { MaintenanceMode } from './MaintenanceMode';
|
||||
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
||||
import { setMaintenanceModeCallback, api } from '../config/api';
|
||||
import { setMaintenanceModeCallback } from '../config/api';
|
||||
|
||||
interface MaintenanceWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Maintenance detection now lives in two places:
|
||||
// Maintenance detection lives in two places:
|
||||
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
|
||||
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
|
||||
// maintenance_mode field (via the shared usePublicSettings hook).
|
||||
// This wrapper only needs to gate the rendered tree on the resulting state.
|
||||
//
|
||||
// The maintenance screen ONLY blocks customer/gallery/public routes. Admin
|
||||
// routes (/admin/*) are never blocked: an admin must always be able to reach
|
||||
// the panel to turn maintenance back off, and the admin auth layer already
|
||||
// handles access (AdminLayout redirects a logged-out admin to /admin/login).
|
||||
// Gating /admin/* here on an "is the admin logged in?" check is what caused the
|
||||
// lockout — it hid the login page itself, and after login the check went stale
|
||||
// (login → dashboard is a client-side nav within /admin, so it never re-ran),
|
||||
// leaving a logged-in admin stuck on the maintenance screen.
|
||||
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
||||
const [hasAdminSession, setHasAdminSession] = useState(false);
|
||||
|
||||
const isAdminRoute = location.pathname.startsWith('/admin');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const checkAdminSession = async () => {
|
||||
if (!isAdminRoute) {
|
||||
setHasAdminSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
|
||||
if (isMounted) {
|
||||
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setHasAdminSession(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAdminSession();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isAdminRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaintenanceModeCallback((enabled: boolean) => {
|
||||
setMaintenanceMode(enabled);
|
||||
});
|
||||
}, [setMaintenanceMode]);
|
||||
|
||||
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
|
||||
if (isMaintenanceMode && !isAdminRoute) {
|
||||
return <MaintenanceMode />;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar } from 'lucide-react';
|
||||
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
@@ -39,6 +39,13 @@ export const ClientsLayout: React.FC = () => {
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
to: '/admin/clients/projects',
|
||||
label: t('clients.subnav.overview', 'Overview'),
|
||||
icon: FolderKanban,
|
||||
featureFlag: 'projects',
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
to: '/admin/clients/accounts',
|
||||
|
||||
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||
import { businessProfileService } from '../../services/businessProfile.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { ProjectSelect } from './ProjectSelect';
|
||||
|
||||
export interface HoursSectionProps {
|
||||
customerId: number;
|
||||
@@ -54,6 +55,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [rateOverride, setRateOverride] = useState<string>('');
|
||||
const [description, setDescription] = useState('');
|
||||
// Migration 118 — optional "book to project" link (gated component).
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
|
||||
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
|
||||
// the end-time jumps to start + duration. Pure convenience; the End
|
||||
@@ -114,6 +117,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||
})(),
|
||||
description: description || null,
|
||||
projectId: projectId ?? null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
@@ -123,6 +127,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
setDuration('');
|
||||
setRateOverride('');
|
||||
setDescription('');
|
||||
setProjectId(null);
|
||||
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -135,6 +140,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
|
||||
return;
|
||||
}
|
||||
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
|
||||
toast.error(t('projects.error.customerMismatch',
|
||||
"That project belongs to a different customer than this entry."));
|
||||
return;
|
||||
}
|
||||
const msg = err?.response?.data?.error || err?.message
|
||||
|| t('customers.hours.error.createFailed', 'Failed to log entry');
|
||||
toast.error(msg);
|
||||
@@ -348,6 +358,14 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
placeholder={t('customers.hours.form.notePlaceholder',
|
||||
'What was worked on?') as string} />
|
||||
</div>
|
||||
{/* Book to project — renders only when the projects feature is on. */}
|
||||
<ProjectSelect
|
||||
className="mt-3"
|
||||
label={t('customers.hours.form.bookToProject', 'Book to project') as string}
|
||||
value={projectId}
|
||||
customerAccountId={customerId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-3">
|
||||
{noRateConfigured && !overrideTyped && (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -26,10 +26,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Button, Input } from '../common';
|
||||
import { Button, Input, LocalizedDateInput } from '../common';
|
||||
import type { PaymentTermInstallment } from '../../services/quotes.service';
|
||||
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export type InstallmentPlan = PaymentTermInstallment[];
|
||||
|
||||
@@ -69,7 +68,6 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
value, onChange, onValidityChange, eventDate, disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dateInputLang } = useLocalizedDate();
|
||||
const defaults = useInstallmentDefaults();
|
||||
const [advanced, setAdvanced] = React.useState(false);
|
||||
|
||||
@@ -230,12 +228,9 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
'On delivery — admin releases manually. Switch to advanced to change.')}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
<LocalizedDateInput
|
||||
value={previewDate(row) || ''}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
const offset = daysBetween(todayIso(), next);
|
||||
update(idx, { trigger: 'fixed_date', offset_days: offset });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* ProjectSelect — a gated project picker reused by the quote / contract /
|
||||
* hours / event editors to link a document to a Project Overview project.
|
||||
*
|
||||
* Renders nothing when the `projects` feature flag is off, so every call
|
||||
* site stays a one-liner that simply vanishes when the feature is disabled
|
||||
* (the maintainer's "book to project must not show unless projects is
|
||||
* enabled" requirement). Customers never see this — admin surfaces only.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { projectsService } from '../../services/projects.service';
|
||||
|
||||
interface ProjectSelectProps {
|
||||
value: number | null;
|
||||
onChange: (projectId: number | null) => void;
|
||||
/** Optional label above the select. When omitted the select renders bare. */
|
||||
label?: string;
|
||||
/** Restrict the list to a single customer's projects when set. */
|
||||
customerAccountId?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ProjectSelect: React.FC<ProjectSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
customerAccountId,
|
||||
disabled,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects', 'select'],
|
||||
queryFn: () => projectsService.list(),
|
||||
enabled: !!flags.projects,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Hard gate: hidden entirely when the feature is off.
|
||||
if (!flags.projects) return null;
|
||||
|
||||
const options = (projects || []).filter(
|
||||
(p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value ?? ''}
|
||||
disabled={disabled || isLoading}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
|
||||
>
|
||||
<option value="">{t('projects.picker.none', 'No project')}</option>
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user