bottleneckcrm/mobile/lib/data/models/invoice_template.dart
Ashwin c11ced7894 feat: add invoice template form screen and integrate with templates list
- Implemented `InvoiceTemplateFormScreen` for creating and editing invoice templates.
- Added functionality to pre-fill form fields from existing templates.
- Enhanced `InvoiceTemplatesScreen` to allow admins to create new templates and set defaults.
- Updated routing to include paths for creating and editing templates.
- Added tests for the new template form and its integration with the templates list.
- Ensured proper handling of admin-only actions and user permissions.
2026-08-08 15:47:13 +05:30

136 lines
5.1 KiB
Dart

/// A PDF template, from `InvoiceTemplateListSerializer`.
///
/// **This model deliberately has no `templateHtml` or `templateCss` field.**
/// Those two are org-authored HTML and CSS that WeasyPrint renders into a PDF
/// server-side, and the backend keeps them off every list, detail and nested
/// response for that reason: the moment either reaches a client that renders
/// markup, a PDF setting becomes stored XSS. Only `InvoiceTemplateEditorView`
/// returns them, it is admin-only, and this app does not call it.
///
/// That absence is what makes the phone's editor safe rather than what stops
/// it. Every other field on a template is ordinary form input, all of it is on
/// the list response, and `PUT /invoices/templates/<id>/` is partial. So the
/// phone can pre-fill from the row it already has, write the ordinary fields
/// back, and never name the two markup fields in either direction. A save from
/// here leaves whatever the web editor last stored untouched.
class InvoiceTemplate {
const InvoiceTemplate({
required this.id,
this.name = '',
this.isDefault = false,
this.primaryColor,
this.secondaryColor,
this.hasLogo = false,
this.hasCustomHtml = false,
this.hasCustomCss = false,
this.defaultNotes = '',
this.defaultTerms = '',
this.footerText = '',
this.usedOnInvoices = 0,
this.updatedBy,
});
final String id;
final String name;
final bool isDefault;
final String? primaryColor;
final String? secondaryColor;
final bool hasLogo;
/// Whether custom markup exists, never what it says.
final bool hasCustomHtml;
final bool hasCustomCss;
/// The boilerplate a new invoice starts from. Plain text, editable here.
final String defaultNotes;
final String defaultTerms;
final String footerText;
final int usedOnInvoices;
final String? updatedBy;
/// The model defaults, used when a form has nothing to pre-fill from.
static const String defaultPrimaryColor = '#3B82F6';
static const String defaultSecondaryColor = '#1E40AF';
factory InvoiceTemplate.fromJson(Map<String, dynamic> json) {
return InvoiceTemplate(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
isDefault: json['is_default'] as bool? ?? false,
primaryColor: json['primary_color'] as String?,
secondaryColor: json['secondary_color'] as String?,
hasLogo: json['has_logo'] as bool? ?? false,
hasCustomHtml: json['has_custom_html'] as bool? ?? false,
hasCustomCss: json['has_custom_css'] as bool? ?? false,
defaultNotes: json['default_notes'] as String? ?? '',
defaultTerms: json['default_terms'] as String? ?? '',
footerText: json['footer_text'] as String? ?? '',
usedOnInvoices: json['used_on_invoices'] as int? ?? 0,
updatedBy: json['updated_by'] as String?,
);
}
}
/// What the form screen submits.
///
/// A separate type from [InvoiceTemplate] because the two carry different
/// things: the model holds what a template *is*, including the read-only
/// `has_custom_html` and `used_on_invoices` the list computes, while this holds
/// only the fields a person may write. Sending a model back would mean deciding
/// per field whether it is writable at the call site, which is how a read-only
/// field ends up in a payload.
class InvoiceTemplateDraft {
const InvoiceTemplateDraft({
required this.name,
required this.primaryColor,
required this.secondaryColor,
this.defaultNotes = '',
this.defaultTerms = '',
this.footerText = '',
this.isDefault,
});
final String name;
final String primaryColor;
final String secondaryColor;
final String defaultNotes;
final String defaultTerms;
final String footerText;
/// Null on an edit, where this field is deliberately not the form's to write.
///
/// `is_default` is a singleton: the model clears the flag on every other
/// template in the org inside a transaction. Carrying an off switch through
/// an unrelated save would demote the org's current default and leave it with
/// none, so the list owns it and both edit forms leave it out. The web's edit
/// form does the same, for the same reason.
final bool? isDefault;
/// `#RRGGBB`, the only form the API accepts. Matches the regex in
/// `_validate_hex_color` and the one in the web's `templates.js`.
static final RegExp hexColor = RegExp(r'^#[0-9a-fA-F]{6}$');
static bool isHexColor(String value) => hexColor.hasMatch(value.trim());
Map<String, dynamic> toPayload() {
return <String, dynamic>{
'name': name.trim(),
'primary_color': primaryColor,
'secondary_color': secondaryColor,
'default_notes': defaultNotes,
'default_terms': defaultTerms,
'footer_text': footerText,
if (isDefault != null) 'is_default': isDefault,
};
}
/// The same payload as multipart fields, for the calls that carry a logo.
///
/// `http.MultipartRequest.fields` is `Map<String, String>`, so `is_default`
/// arrives as "true" or "false". DRF's `BooleanField` reads both, and it is
/// the same spelling the web sends through `FormData`.
Map<String, String> toMultipartFields() {
return toPayload().map((key, value) => MapEntry(key, '$value'));
}
}