feat(projects): gated project pickers on quote/contract/hours editors
- ProjectSelect: a reusable picker that renders nothing when the projects flag is off (satisfies 'book to project hidden unless projects enabled'). - projects.service.ts: full frontend API client (list/get/create/update, overview, assign event/quote/contract, email preview + 4 actions). - Quote + contract editors carry an optional projectId (state, prefill, payload); service payload/detail types updated. - HoursSection gains a 'book to project' control; backend createEntry persists project_id (migration 118, hasColumnCached guarded).
This commit is contained in:
@@ -572,6 +572,7 @@ router.post('/:id/hour-entries', [
|
|||||||
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
|
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
|
||||||
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
|
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
|
||||||
|
body('projectId').optional({ nullable: true }).isInt({ min: 1 }),
|
||||||
], handleAsync(async (req, res) => {
|
], handleAsync(async (req, res) => {
|
||||||
validateRequest(req);
|
validateRequest(req);
|
||||||
const result = await customerHoursService.createEntry(
|
const result = await customerHoursService.createEntry(
|
||||||
|
|||||||
@@ -233,6 +233,10 @@ async function createEntry(customerId, payload, adminId) {
|
|||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
|
// Migration 118 — optional "book to project" link.
|
||||||
|
if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) {
|
||||||
|
row.project_id = payload.projectId || null;
|
||||||
|
}
|
||||||
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
|
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
|
||||||
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
|||||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||||
import { businessProfileService } from '../../services/businessProfile.service';
|
import { businessProfileService } from '../../services/businessProfile.service';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
import { ProjectSelect } from './ProjectSelect';
|
||||||
|
|
||||||
export interface HoursSectionProps {
|
export interface HoursSectionProps {
|
||||||
customerId: number;
|
customerId: number;
|
||||||
@@ -54,6 +55,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
const [duration, setDuration] = useState<string>('');
|
const [duration, setDuration] = useState<string>('');
|
||||||
const [rateOverride, setRateOverride] = useState<string>('');
|
const [rateOverride, setRateOverride] = useState<string>('');
|
||||||
const [description, setDescription] = useState('');
|
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
|
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
|
||||||
// the end-time jumps to start + duration. Pure convenience; the End
|
// 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;
|
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||||
})(),
|
})(),
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
projectId: projectId ?? null,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||||
@@ -123,6 +127,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
setDuration('');
|
setDuration('');
|
||||||
setRateOverride('');
|
setRateOverride('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
setProjectId(null);
|
||||||
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -348,6 +353,13 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
placeholder={t('customers.hours.form.notePlaceholder',
|
placeholder={t('customers.hours.form.notePlaceholder',
|
||||||
'What was worked on?') as string} />
|
'What was worked on?') as string} />
|
||||||
</div>
|
</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}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
<div className="mt-3 flex items-center justify-end gap-3">
|
<div className="mt-3 flex items-center justify-end gap-3">
|
||||||
{noRateConfigured && !overrideTyped && (
|
{noRateConfigured && !overrideTyped && (
|
||||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
CONTRACT_SECTIONS,
|
CONTRACT_SECTIONS,
|
||||||
} from '../../../services/contracts.service';
|
} from '../../../services/contracts.service';
|
||||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||||
|
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||||
|
|
||||||
interface BlockRow {
|
interface BlockRow {
|
||||||
blockId: number;
|
blockId: number;
|
||||||
@@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
const [language, setLanguage] = useState('de');
|
const [language, setLanguage] = useState('de');
|
||||||
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
|
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [validUntil, setValidUntil] = useState('');
|
const [validUntil, setValidUntil] = useState('');
|
||||||
|
const [projectId, setProjectId] = useState<number | null>(null);
|
||||||
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
||||||
|
|
||||||
// Load existing contract on edit.
|
// Load existing contract on edit.
|
||||||
@@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
setLanguage(c.language || 'de');
|
setLanguage(c.language || 'de');
|
||||||
setIssueDate(c.issueDate);
|
setIssueDate(c.issueDate);
|
||||||
setValidUntil(c.validUntil || '');
|
setValidUntil(c.validUntil || '');
|
||||||
|
setProjectId(c.projectId ?? null);
|
||||||
setBlocks((c.inclusions || []).map((inc) => ({
|
setBlocks((c.inclusions || []).map((inc) => ({
|
||||||
blockId: inc.blockId,
|
blockId: inc.blockId,
|
||||||
section: inc.section,
|
section: inc.section,
|
||||||
@@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
outroText: outroText || null,
|
outroText: outroText || null,
|
||||||
issueDate,
|
issueDate,
|
||||||
validUntil: validUntil || undefined,
|
validUntil: validUntil || undefined,
|
||||||
|
projectId: projectId ?? null,
|
||||||
});
|
});
|
||||||
// Apply block toggles + ordering as an update right after create.
|
// Apply block toggles + ordering as an update right after create.
|
||||||
await contractsService.update(created.contract.id, {
|
await contractsService.update(created.contract.id, {
|
||||||
@@ -220,6 +224,7 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
language,
|
language,
|
||||||
issueDate,
|
issueDate,
|
||||||
validUntil: validUntil || undefined,
|
validUntil: validUntil || undefined,
|
||||||
|
projectId: projectId ?? null,
|
||||||
blocks: blocks.map((b) => ({
|
blocks: blocks.map((b) => ({
|
||||||
blockId: b.blockId, included: b.included, position: b.position,
|
blockId: b.blockId, included: b.included, position: b.position,
|
||||||
})),
|
})),
|
||||||
@@ -348,6 +353,16 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Project link (renders only when the projects feature is on). */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<ProjectSelect
|
||||||
|
label={t('projects.picker.label', 'Project') as string}
|
||||||
|
value={projectId}
|
||||||
|
customerAccountId={customerAccountId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '../../../services/quotes.service';
|
} from '../../../services/quotes.service';
|
||||||
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||||
|
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
import { userManagementService } from '../../../services/userManagement.service';
|
import { userManagementService } from '../../../services/userManagement.service';
|
||||||
@@ -59,6 +60,8 @@ interface FormState {
|
|||||||
internalNotes: string;
|
internalNotes: string;
|
||||||
ccPdfEmail: string;
|
ccPdfEmail: string;
|
||||||
businessBankAccountId: number | null;
|
businessBankAccountId: number | null;
|
||||||
|
/** Migration 121 — optional Project Overview link. */
|
||||||
|
projectId: number | null;
|
||||||
lineItems: EditableLineItem[];
|
lineItems: EditableLineItem[];
|
||||||
// Ad-hoc installments (commit #6). null = use the payment-timing
|
// Ad-hoc installments (commit #6). null = use the payment-timing
|
||||||
// template's installments; array = explicit per-quote override.
|
// template's installments; array = explicit per-quote override.
|
||||||
@@ -88,6 +91,7 @@ const empty: FormState = {
|
|||||||
internalNotes: '',
|
internalNotes: '',
|
||||||
ccPdfEmail: '',
|
ccPdfEmail: '',
|
||||||
businessBankAccountId: null,
|
businessBankAccountId: null,
|
||||||
|
projectId: null,
|
||||||
lineItems: [],
|
lineItems: [],
|
||||||
installments: null,
|
installments: null,
|
||||||
};
|
};
|
||||||
@@ -123,6 +127,8 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
|||||||
internalNotes: f.internalNotes || undefined,
|
internalNotes: f.internalNotes || undefined,
|
||||||
ccPdfEmail: f.ccPdfEmail || undefined,
|
ccPdfEmail: f.ccPdfEmail || undefined,
|
||||||
businessBankAccountId: f.businessBankAccountId || undefined,
|
businessBankAccountId: f.businessBankAccountId || undefined,
|
||||||
|
// Migration 121 — Project Overview link. Send null to clear.
|
||||||
|
projectId: f.projectId ?? null,
|
||||||
lineItems: f.lineItems.map((li) => ({
|
lineItems: f.lineItems.map((li) => ({
|
||||||
position: li.position,
|
position: li.position,
|
||||||
quantity: li.quantity,
|
quantity: li.quantity,
|
||||||
@@ -214,6 +220,7 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
internalNotes: q.internalNotes || '',
|
internalNotes: q.internalNotes || '',
|
||||||
ccPdfEmail: q.ccPdfEmail || '',
|
ccPdfEmail: q.ccPdfEmail || '',
|
||||||
businessBankAccountId: q.businessBankAccountId,
|
businessBankAccountId: q.businessBankAccountId,
|
||||||
|
projectId: q.projectId ?? null,
|
||||||
lineItems: existing.lineItems.map((li) => ({
|
lineItems: existing.lineItems.map((li) => ({
|
||||||
id: li.id,
|
id: li.id,
|
||||||
position: li.position,
|
position: li.position,
|
||||||
@@ -479,6 +486,15 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
}))}
|
}))}
|
||||||
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
|
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
|
||||||
/>
|
/>
|
||||||
|
{/* Project link (renders only when the projects feature is on). */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<ProjectSelect
|
||||||
|
label={t('projects.picker.label', 'Project') as string}
|
||||||
|
value={form.projectId}
|
||||||
|
customerAccountId={form.customerAccountId}
|
||||||
|
onChange={(projectId) => setForm((f) => ({ ...f, projectId }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section: Event */}
|
{/* Section: Event */}
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export interface ContractSummary {
|
|||||||
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
|
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
|
||||||
dealUuid: string | null;
|
dealUuid: string | null;
|
||||||
customerAccountId: number;
|
customerAccountId: number;
|
||||||
|
/** Migration 121 — Project Overview link (null when unlinked). */
|
||||||
|
projectId: number | null;
|
||||||
customer: {
|
customer: {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
|
|||||||
outroText?: string | null;
|
outroText?: string | null;
|
||||||
issueDate?: string;
|
issueDate?: string;
|
||||||
validUntil?: string;
|
validUntil?: string;
|
||||||
|
/** Migration 121 — optional link to a Project Overview project. */
|
||||||
|
projectId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractUpdatePayload {
|
export interface ContractUpdatePayload {
|
||||||
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
|
|||||||
* rows from this payload — caller controls inclusion + per-section
|
* rows from this payload — caller controls inclusion + per-section
|
||||||
* order via the position field. Omit to leave inclusions untouched. */
|
* order via the position field. Omit to leave inclusions untouched. */
|
||||||
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
|
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
|
||||||
|
/** Migration 121 — optional Project Overview link. null clears it. */
|
||||||
|
projectId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractBlockCreatePayload {
|
export interface ContractBlockCreatePayload {
|
||||||
|
|||||||
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
|
|||||||
endTime: string; // HH:MM
|
endTime: string; // HH:MM
|
||||||
hourlyRateMinorOverride?: number | null;
|
hourlyRateMinorOverride?: number | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/** Migration 118 — optional "book to project" link. */
|
||||||
|
projectId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HourEntryUpdatePayload {
|
export interface HourEntryUpdatePayload {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Projects API client. Hits /api/admin/projects/*.
|
||||||
|
*
|
||||||
|
* Projects are the admin-only grouping layer above events (Model A). The
|
||||||
|
* cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
|
||||||
|
* the contracts/bills service shape: `data.data || data` unwrap.
|
||||||
|
*/
|
||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export type ProjectStatus = 'active' | 'archived' | string;
|
||||||
|
|
||||||
|
export interface ProjectSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
customerAccountId: number | null;
|
||||||
|
customerEmail: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
eventCount?: number;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectEvent {
|
||||||
|
id: number;
|
||||||
|
event_name: string;
|
||||||
|
event_date: string | null;
|
||||||
|
slug: string;
|
||||||
|
is_active: boolean | number;
|
||||||
|
is_draft: boolean | number;
|
||||||
|
expires_at: string | null;
|
||||||
|
is_archived: boolean | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectEmail {
|
||||||
|
id: number;
|
||||||
|
recipient: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
queuedAt: string | null;
|
||||||
|
sentAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
eventId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectInvoice {
|
||||||
|
id: number;
|
||||||
|
invoice_number: string;
|
||||||
|
status: string;
|
||||||
|
kind: string | null;
|
||||||
|
issue_date: string | null;
|
||||||
|
due_date: string | null;
|
||||||
|
total_amount_minor: number;
|
||||||
|
paid_amount_minor: number | null;
|
||||||
|
paid_at: string | null;
|
||||||
|
currency: string;
|
||||||
|
event_id: number | null;
|
||||||
|
deal_uuid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectQuote {
|
||||||
|
id: number;
|
||||||
|
quote_number: string;
|
||||||
|
status: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
valid_until: string | null;
|
||||||
|
total_amount_minor: number;
|
||||||
|
currency: string;
|
||||||
|
deal_uuid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectContract {
|
||||||
|
id: number;
|
||||||
|
contract_number: string;
|
||||||
|
status: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
signed_by_customer_at: string | null;
|
||||||
|
deal_uuid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHourEntry {
|
||||||
|
id: number;
|
||||||
|
entry_date: string | null;
|
||||||
|
duration_minutes: number;
|
||||||
|
description: string | null;
|
||||||
|
status: string | null;
|
||||||
|
invoice_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMilestone {
|
||||||
|
kind: 'quote' | 'contract' | 'gallery' | 'invoice';
|
||||||
|
label: string;
|
||||||
|
date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectOverview {
|
||||||
|
project: ProjectSummary;
|
||||||
|
events: ProjectEvent[];
|
||||||
|
emails: ProjectEmail[];
|
||||||
|
quotes: ProjectQuote[];
|
||||||
|
contracts: ProjectContract[];
|
||||||
|
invoices: ProjectInvoice[];
|
||||||
|
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
|
||||||
|
milestones: ProjectMilestone[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailPreview {
|
||||||
|
id: number;
|
||||||
|
recipient: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
available: boolean;
|
||||||
|
html: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsService = {
|
||||||
|
async list(params: { q?: string; status?: string } = {}): Promise<ProjectSummary[]> {
|
||||||
|
const { data } = await api.get('/admin/projects', { params });
|
||||||
|
const body = data.data || data;
|
||||||
|
return body.projects || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: number): Promise<ProjectSummary> {
|
||||||
|
const { data } = await api.get(`/admin/projects/${id}`);
|
||||||
|
const body = data.data || data;
|
||||||
|
return body.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload: { name: string; customerAccountId?: number | null }): Promise<ProjectSummary> {
|
||||||
|
const { data } = await api.post('/admin/projects', payload);
|
||||||
|
const body = data.data || data;
|
||||||
|
return body.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
payload: { name?: string; customerAccountId?: number | null; status?: string },
|
||||||
|
): Promise<ProjectSummary> {
|
||||||
|
const { data } = await api.put(`/admin/projects/${id}`, payload);
|
||||||
|
const body = data.data || data;
|
||||||
|
return body.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async overview(id: number): Promise<ProjectOverview> {
|
||||||
|
const { data } = await api.get(`/admin/projects/${id}/overview`);
|
||||||
|
return (data.data || data) as ProjectOverview;
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignEvent(projectId: number, eventId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/${projectId}/events`, { eventId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignQuote(projectId: number, quoteId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignContract(projectId: number, contractId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async emailPreview(emailId: number): Promise<EmailPreview> {
|
||||||
|
const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
|
||||||
|
return (data.data || data) as EmailPreview;
|
||||||
|
},
|
||||||
|
|
||||||
|
async resendEmail(emailId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/email/${emailId}/resend`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelEmail(emailId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/email/${emailId}/cancel`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async retryEmail(emailId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/email/${emailId}/retry`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendEmailNow(emailId: number): Promise<void> {
|
||||||
|
await api.post(`/admin/projects/email/${emailId}/send-now`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -43,6 +43,8 @@ export interface QuoteSummary {
|
|||||||
* doc — contract, invoices, Storni — that shares this deal. */
|
* doc — contract, invoices, Storni — that shares this deal. */
|
||||||
dealUuid: string | null;
|
dealUuid: string | null;
|
||||||
customerAccountId: number;
|
customerAccountId: number;
|
||||||
|
/** Migration 121 — Project Overview link (null when unlinked). */
|
||||||
|
projectId: number | null;
|
||||||
customer: {
|
customer: {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
|
|||||||
internalNotes?: string;
|
internalNotes?: string;
|
||||||
ccPdfEmail?: string;
|
ccPdfEmail?: string;
|
||||||
businessBankAccountId?: number;
|
businessBankAccountId?: number;
|
||||||
|
/** Migration 121 — optional link to a Project Overview project.
|
||||||
|
* null clears the link; undefined leaves it unchanged. */
|
||||||
|
projectId?: number | null;
|
||||||
lineItems: QuoteLineItem[];
|
lineItems: QuoteLineItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user