feat: update currency handling across various components
- Enhanced the money formatting function to include organization-specific currency in goals, invoices, leads, and pipeline pages. - Updated the display of financial figures to reflect the correct currency in the dashboard and deal forms. - Improved the handling of foreign currency deals in the dashboard, ensuring they are displayed correctly without being filtered out. - Added tests to verify that amounts are displayed in the correct currency and that the deal form initializes with the organization's currency.
This commit is contained in:
parent
4d2b67f7d8
commit
1d2d8f24d9
35 changed files with 586 additions and 146 deletions
|
|
@ -909,3 +909,75 @@ class TestGoogleOrgListExcludesDeactivated:
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
returned = {o["id"] for o in response.data["organizations"]}
|
||||
assert returned == {str(org_a.id)}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestOrgPayloadCarriesCurrency:
|
||||
"""Sign-in and org switch must say what currency an org keeps its books in.
|
||||
|
||||
Both clients parse `default_currency` and `currency_symbol` off the org
|
||||
records these endpoints return, and no endpoint was sending either. The
|
||||
mobile deal and lead forms therefore defaulted every new record to USD,
|
||||
and the mobile dashboard printed a dollar sign over an org's totals
|
||||
whatever currency they were actually in.
|
||||
"""
|
||||
|
||||
@patch("google.oauth2.id_token.verify_oauth2_token")
|
||||
@patch("google.auth.transport.requests.Request")
|
||||
def test_google_sign_in_lists_the_currency(
|
||||
self,
|
||||
mock_request_cls,
|
||||
mock_verify,
|
||||
unauthenticated_client,
|
||||
admin_user,
|
||||
admin_profile,
|
||||
org_a,
|
||||
):
|
||||
org_a.default_currency = "EUR"
|
||||
org_a.save(update_fields=["default_currency"])
|
||||
mock_verify.return_value = {
|
||||
"email": admin_user.email,
|
||||
"email_verified": True,
|
||||
"picture": "",
|
||||
}
|
||||
|
||||
response = unauthenticated_client.post(
|
||||
"/api/auth/google/", {"idToken": "valid-token"}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
listed = response.data["organizations"][0]
|
||||
assert listed["default_currency"] == "EUR"
|
||||
assert listed["currency_symbol"] == "€"
|
||||
|
||||
def test_org_switch_returns_the_target_org_currency(
|
||||
self, admin_client, admin_user, org_b
|
||||
):
|
||||
org_b.default_currency = "INR"
|
||||
org_b.save(update_fields=["default_currency"])
|
||||
Profile.objects.create(user=admin_user, org=org_b, role="ADMIN")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/auth/switch-org/", {"org_id": str(org_b.id)}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["current_org"]["default_currency"] == "INR"
|
||||
assert response.data["current_org"]["currency_symbol"] == "₹"
|
||||
|
||||
def test_an_org_with_no_currency_set_falls_back_to_dollars(
|
||||
self, admin_client, admin_user, org_b
|
||||
):
|
||||
"""`default_currency` is nullable in practice, so the symbol lookup
|
||||
has to answer for an empty value rather than raise or return None."""
|
||||
org_b.default_currency = ""
|
||||
org_b.save(update_fields=["default_currency"])
|
||||
Profile.objects.create(user=admin_user, org=org_b, role="ADMIN")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/auth/switch-org/", {"org_id": str(org_b.id)}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["current_org"]["default_currency"] == "USD"
|
||||
assert response.data["current_org"]["currency_symbol"] == "$"
|
||||
|
|
|
|||
|
|
@ -495,6 +495,21 @@ class TestVerifyCodeOrgSelection:
|
|||
assert response.data["current_org"]["id"] == str(org_a.id)
|
||||
assert [o["id"] for o in response.data["organizations"]] == [str(org_a.id)]
|
||||
|
||||
def test_the_listed_org_says_which_currency_it_uses(
|
||||
self, unauthenticated_client, admin_user, admin_profile, org_a
|
||||
):
|
||||
"""The client defaults a new deal's currency from this record, so an
|
||||
org list without a currency made every org look like a dollar org."""
|
||||
org_a.default_currency = "GBP"
|
||||
org_a.save(update_fields=["default_currency"])
|
||||
self._code_token(admin_user.email)
|
||||
|
||||
response = self._verify(unauthenticated_client, admin_user.email)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["organizations"][0]["default_currency"] == "GBP"
|
||||
assert response.data["organizations"][0]["currency_symbol"] == "£"
|
||||
|
||||
def test_multi_org_user_is_offered_the_choice_instead_of_one_being_made(
|
||||
self, unauthenticated_client, admin_user, admin_profile, org_a, org_b
|
||||
):
|
||||
|
|
|
|||
|
|
@ -18,10 +18,36 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
|
|||
from common import serializer
|
||||
from common.models import Org, Profile, User
|
||||
from common.serializer import OrgAwareRefreshToken
|
||||
from common.utils import CURRENCY_SYMBOLS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _org_payload(org, role=None):
|
||||
"""The org record every auth response hands a client.
|
||||
|
||||
Currency belongs here because a client picks it up at sign-in and holds it
|
||||
for the whole session: the mobile deal and lead forms default a new
|
||||
record's currency from it, and the dashboard prices its totals with it.
|
||||
All three read fields that no endpoint was sending, so an org keeping its
|
||||
books in euros still created dollar deals and drew a dollar dashboard.
|
||||
|
||||
``default_currency`` is normalised rather than passed through, so a blank
|
||||
column answers with the same "USD" the rest of the codebase assumes.
|
||||
"""
|
||||
currency = org.default_currency or "USD"
|
||||
payload = {
|
||||
"id": str(org.id),
|
||||
"name": org.name,
|
||||
"default_currency": currency,
|
||||
"currency_symbol": CURRENCY_SYMBOLS.get(currency, "$"),
|
||||
"default_country": org.default_country,
|
||||
}
|
||||
if role is not None:
|
||||
payload["role"] = role
|
||||
return payload
|
||||
|
||||
|
||||
def _google_email_is_verified(claims):
|
||||
"""Whether Google asserts the caller controls the email in ``claims``.
|
||||
|
||||
|
|
@ -296,14 +322,7 @@ class GoogleIdTokenView(APIView):
|
|||
profiles = Profile.objects.filter(user=user, is_active=True).select_related(
|
||||
"org"
|
||||
)
|
||||
organizations = [
|
||||
{
|
||||
"id": str(p.org.id),
|
||||
"name": p.org.name,
|
||||
"role": p.role,
|
||||
}
|
||||
for p in profiles
|
||||
]
|
||||
organizations = [_org_payload(p.org, role=p.role) for p in profiles]
|
||||
|
||||
# Generate JWT token
|
||||
token = OrgAwareRefreshToken.for_user_and_org(user, None)
|
||||
|
|
@ -576,7 +595,7 @@ class OrgSwitchView(APIView):
|
|||
{
|
||||
"access_token": str(token.access_token),
|
||||
"refresh_token": str(token),
|
||||
"current_org": {"id": str(profile.org.id), "name": profile.org.name},
|
||||
"current_org": _org_payload(profile.org),
|
||||
"profile": {
|
||||
"id": str(profile.id),
|
||||
"role": profile.role,
|
||||
|
|
@ -764,10 +783,7 @@ class MagicLinkVerifyView(APIView):
|
|||
}
|
||||
|
||||
if default_org:
|
||||
response_data["current_org"] = {
|
||||
"id": str(default_org.id),
|
||||
"name": default_org.name,
|
||||
}
|
||||
response_data["current_org"] = _org_payload(default_org)
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
@ -882,9 +898,7 @@ class MagicLinkVerifyCodeView(APIView):
|
|||
)
|
||||
# Same shape the Google flow returns, so a client can offer the same
|
||||
# picker whichever way the user signed in.
|
||||
organizations = [
|
||||
{"id": str(p.org.id), "name": p.org.name, "role": p.role} for p in profiles
|
||||
]
|
||||
organizations = [_org_payload(p.org, role=p.role) for p in profiles]
|
||||
|
||||
# Bind the session to an org only when there is no choice to make.
|
||||
# Picking `profiles.first()` out of several was an arbitrary answer to
|
||||
|
|
@ -910,8 +924,5 @@ class MagicLinkVerifyCodeView(APIView):
|
|||
"organizations": organizations,
|
||||
}
|
||||
if default_org:
|
||||
response_data["current_org"] = {
|
||||
"id": str(default_org.id),
|
||||
"name": default_org.name,
|
||||
}
|
||||
response_data["current_org"] = _org_payload(default_org)
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -256,6 +256,10 @@ export async function getAccount({ cookies }, id) {
|
|||
name: deal.name ?? '',
|
||||
stage: deal.stage,
|
||||
amount: num(deal.amount) ?? 0,
|
||||
// Each deal is priced in its own currency; the account's rollups above
|
||||
// are sums and take the org's. Dropping this printed every deal on the
|
||||
// page as dollars.
|
||||
currency: deal.currency || 'USD',
|
||||
closed_on: deal.closed_on ?? null,
|
||||
// Real fields, from the stage-aging chain the deal module already owns.
|
||||
days_in_current_stage: deal.days_in_stage ?? 0,
|
||||
|
|
@ -283,6 +287,7 @@ export async function getAccount({ cookies }, id) {
|
|||
status: invoice.status,
|
||||
total_amount: num(invoice.total_amount) ?? 0,
|
||||
amount_due: num(invoice.amount_due) ?? 0,
|
||||
currency: invoice.currency || 'USD',
|
||||
due_date: invoice.due_date ?? null,
|
||||
past_due: isPastDue(invoice)
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -194,6 +194,9 @@ export async function getContact({ cookies }, id) {
|
|||
name: deal.name ?? '',
|
||||
stage: deal.stage,
|
||||
amount: num(deal.amount) ?? 0,
|
||||
// Priced in the deal's own currency, unlike the open-pipeline sum beside
|
||||
// it, which is an addition across deals and takes the org's.
|
||||
currency: deal.currency || 'USD',
|
||||
closed_on: deal.closed_on ?? null
|
||||
})),
|
||||
tickets: (response.cases ?? []).map((/** @type {any} */ ticket) => ({
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ function toRow(lead) {
|
|||
status: lead.status ?? 'assigned',
|
||||
source: lead.source ?? '',
|
||||
opportunity_amount: lead.opportunity_amount ? Number(lead.opportunity_amount) : null,
|
||||
// A lead's amount is stored in the lead's own currency, which the
|
||||
// serializer stamps from the org default at create time and the user can
|
||||
// change afterwards. Dropping it here left every lead amount printing as
|
||||
// dollars, the same defect the deal list had.
|
||||
currency: lead.currency || 'USD',
|
||||
assigned_to: ownerName(lead),
|
||||
// Null means never contacted, and the column says so rather than
|
||||
// substituting `updated_at`. An edit is not a conversation, and a list
|
||||
|
|
|
|||
|
|
@ -121,7 +121,21 @@ export async function load(event) {
|
|||
counts: /** @type {Record<string, number>} */ ({}),
|
||||
org: {
|
||||
name: event.locals.org?.name || 'BottleCRM',
|
||||
terminology: /** @type {Record<string, string> | undefined} */ (undefined)
|
||||
terminology: /** @type {Record<string, string> | undefined} */ (undefined),
|
||||
// The currency for figures that are sums rather than one record: pipeline
|
||||
// totals, invoice ageing, goal progress. A per-record currency cannot
|
||||
// label a sum, and `money()` falls back to a hardcoded USD when it is not
|
||||
// told, which printed a dollar sign over a euro figure on every page that
|
||||
// forgot. It lives here rather than in each page loader because 17 of
|
||||
// them needed it and copying the same line 17 times is how the first one
|
||||
// fell out of step.
|
||||
//
|
||||
// This does not make a mixed-currency sum correct: the API adds `amount`
|
||||
// across rows regardless of currency, so an org running more than one
|
||||
// gets an addition that should not have happened, and no symbol repairs
|
||||
// it. Read from the JWT's org settings, which is also where a new deal's
|
||||
// currency comes from.
|
||||
currency: /** @type {any} */ (event.locals).org_settings?.default_currency || 'USD'
|
||||
},
|
||||
// Server-derived from the JWT (never the client). Display-only: it lets the
|
||||
// shell hide destinations a member can only reach to be turned away. The
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
: summary.quiet_deals === 0
|
||||
? `${plural(summary.count, 'thing wants', 'things want')} you today.`
|
||||
: `${plural(summary.count, 'thing wants', 'things want')} you today. ` +
|
||||
`${plural(summary.quiet_deals, 'deal', 'deals')} worth ${money(summary.quiet_value)} ` +
|
||||
`${plural(summary.quiet_deals, 'deal', 'deals')} worth ${money(summary.quiet_value, data.org.currency)} ` +
|
||||
`${summary.quiet_deals === 1 ? 'has' : 'have'} gone quiet. Those are first.`
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -103,19 +103,19 @@
|
|||
<!-- All four figures are annotated in SQL by the API over the
|
||||
whole related set, never derived from the rows on screen. -->
|
||||
<td class="v2-r v2-num" data-m="hide">
|
||||
{a.won_amount ? money(a.won_amount) : '—'}
|
||||
{a.won_amount ? money(a.won_amount, data.org.currency) : '—'}
|
||||
</td>
|
||||
<!-- Labelled on a phone: without the header row, two money
|
||||
columns side by side are two unattributed numbers. -->
|
||||
<td class="v2-r v2-num" data-l="Pipeline">
|
||||
{a.open_pipeline ? money(a.open_pipeline) : '—'}
|
||||
{a.open_pipeline ? money(a.open_pipeline, data.org.currency) : '—'}
|
||||
</td>
|
||||
<td
|
||||
class="v2-r v2-num"
|
||||
data-l="Past due"
|
||||
style={a.overdue_amount ? 'color:var(--v2-rust);font-weight:600' : ''}
|
||||
>
|
||||
{a.overdue_amount ? money(a.overdue_amount) : '—'}
|
||||
{a.overdue_amount ? money(a.overdue_amount, data.org.currency) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{#if a.open_tickets}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
: stalled.length
|
||||
? `${stalled[0].name} has not moved in ${stalled[0].days_in_current_stage} days.`
|
||||
: pastDue.length
|
||||
? `${pastDue[0].invoice_number} is past due, ${money(pastDue[0].amount_due)}.`
|
||||
? `${pastDue[0].invoice_number} is past due, ${money(pastDue[0].amount_due, pastDue[0].currency)}.`
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
<div class="v2-stats" style="margin-bottom:16px">
|
||||
<StatCard
|
||||
label="Revenue won"
|
||||
value={account.won_amount ? money(account.won_amount) : '—'}
|
||||
value={account.won_amount ? money(account.won_amount, data.org.currency) : '—'}
|
||||
tone={account.won_amount ? 'moss' : 'slate'}
|
||||
detail={account.won_count
|
||||
? `${account.won_count} deal${account.won_count === 1 ? '' : 's'} won`
|
||||
|
|
@ -87,14 +87,14 @@
|
|||
/>
|
||||
<StatCard
|
||||
label="Open pipeline"
|
||||
value={account.open_pipeline ? money(account.open_pipeline) : '—'}
|
||||
value={account.open_pipeline ? money(account.open_pipeline, data.org.currency) : '—'}
|
||||
detail={account.open_deal_count
|
||||
? `${account.open_deal_count} open deal${account.open_deal_count === 1 ? '' : 's'}`
|
||||
: 'No open deals'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Past due"
|
||||
value={account.overdue_amount ? money(account.overdue_amount) : '—'}
|
||||
value={account.overdue_amount ? money(account.overdue_amount, data.org.currency) : '—'}
|
||||
tone={account.overdue_amount ? 'rust' : 'slate'}
|
||||
detail={pastDue.length
|
||||
? pastDue.map((/** @type {any} */ i) => i.invoice_number).join(', ')
|
||||
|
|
@ -166,7 +166,9 @@
|
|||
{#if d.aging_status === 'red' && !d.stage.startsWith('CLOSED_')}
|
||||
<Pill tone="rust">{d.days_in_current_stage}d</Pill>
|
||||
{/if}
|
||||
<span class="v2-num" style="font-weight:600;font-size:13px">{money(d.amount)}</span>
|
||||
<span class="v2-num" style="font-weight:600;font-size:13px"
|
||||
>{money(d.amount, d.currency)}</span
|
||||
>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="v2-sub" style="padding:14px 15px;font-size:12.5px">
|
||||
|
|
@ -261,7 +263,7 @@
|
|||
{inv.past_due ? 'Past due' : invoiceStatusLabel(inv.status)}
|
||||
</Pill>
|
||||
<span class="v2-num" style="margin-left:auto;font-weight:600;font-size:13px">
|
||||
{money(inv.past_due ? inv.amount_due : inv.total_amount)}
|
||||
{money(inv.past_due ? inv.amount_due : inv.total_amount, inv.currency)}
|
||||
</span>
|
||||
</a>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@
|
|||
: !contact.email && (!contact.phone || contact.do_not_call)
|
||||
? `There is no way to reach ${contact.first_name} on this record: no email${contact.do_not_call ? ', and they asked not to be called' : ' and no phone'}.`
|
||||
: openDeals.length && !contact.owner
|
||||
? `${contact.first_name} is on ${openDeals.length === 1 ? openDeals[0].name : `${openDeals.length} open deals`} worth ${money(openPipeline)}, and nobody owns this record.`
|
||||
? `${contact.first_name} is on ${openDeals.length === 1 ? openDeals[0].name : `${openDeals.length} open deals`} worth ${money(openPipeline, data.org.currency)}, and nobody owns this record.`
|
||||
: overdueTasks.length
|
||||
? `${overdueTasks.length === 1 ? 'A task' : `${overdueTasks.length} tasks`} naming ${contact.first_name} ${overdueTasks.length === 1 ? 'is' : 'are'} past due.`
|
||||
: null
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
<div class="v2-label" style="margin-bottom:10px">
|
||||
Deals they are named on
|
||||
{#if openDeals.length}
|
||||
<span class="v2-num" style="margin-left:6px">{money(openPipeline)}</span> open
|
||||
<span class="v2-num" style="margin-left:6px">{money(openPipeline, data.org.currency)}</span> open
|
||||
{/if}
|
||||
</div>
|
||||
<div class="v2-card" style="overflow:hidden;margin-bottom:22px">
|
||||
|
|
@ -243,7 +243,7 @@
|
|||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="v2-num" style="font-weight:600;font-size:13px">{money(d.amount)}</span>
|
||||
<span class="v2-num" style="font-weight:600;font-size:13px">{money(d.amount, d.currency)}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="v2-sub" style="padding:14px 15px;font-size:12.5px">
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@
|
|||
};
|
||||
|
||||
/** Revenue goals are money; DEALS_CLOSED goals are a count of deals. */
|
||||
const value = (g, n) => (g.goal_type === 'REVENUE' ? money(n) : count(n));
|
||||
const value = (g, n) => (g.goal_type === 'REVENUE' ? money(n, data.org.currency) : count(n));
|
||||
</script>
|
||||
|
||||
<PageHeader title="Goals">
|
||||
{#snippet sub()}
|
||||
<span class="v2-num">{money(totals.achieved)}</span> of
|
||||
<span class="v2-num">{money(totals.target)}</span> across
|
||||
<span class="v2-num">{money(totals.achieved, data.org.currency)}</span> of
|
||||
<span class="v2-num">{money(totals.target, data.org.currency)}</span> across
|
||||
<span class="v2-num">{count(totals.active)}</span> active goals
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
|
|
@ -88,13 +88,13 @@
|
|||
<div class="v2-stats">
|
||||
<StatCard
|
||||
label="Committed"
|
||||
value={money(totals.target)}
|
||||
value={money(totals.target, data.org.currency)}
|
||||
tone="ink"
|
||||
detail="Active goals only"
|
||||
/>
|
||||
<StatCard
|
||||
label="Booked"
|
||||
value={money(totals.achieved)}
|
||||
value={money(totals.achieved, data.org.currency)}
|
||||
tone="moss"
|
||||
detail="Closed-won in period"
|
||||
/>
|
||||
|
|
@ -224,7 +224,7 @@
|
|||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12.5px;font-weight:550">{row.user}</div>
|
||||
<div class="v2-sub v2-num" style="font-size:11px">
|
||||
{money(row.achieved)} of {money(row.target)}
|
||||
{money(row.achieved, data.org.currency)} of {money(row.target, data.org.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Uncapped on purpose: 104% is the interesting number, and
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
let valid = $derived(Object.keys(errors).length === 0);
|
||||
const show = (field) => (touched[field] || submitted) && errors[field];
|
||||
|
||||
const unit = (n) => (form.goal_type === 'REVENUE' ? money(n) : count(n));
|
||||
const unit = (n) => (form.goal_type === 'REVENUE' ? money(n, data.org.currency) : count(n));
|
||||
|
||||
/** @type {import('./$types').SubmitFunction} */
|
||||
const check = async ({ cancel }) => {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
let valid = $derived(Object.keys(errors).length === 0);
|
||||
const show = (field) => (touched[field] || submitted) && errors[field];
|
||||
|
||||
const unit = (n) => (form.goal_type === 'REVENUE' ? money(n) : count(n));
|
||||
const unit = (n) => (form.goal_type === 'REVENUE' ? money(n, data.org.currency) : count(n));
|
||||
|
||||
/** @type {import('./$types').SubmitFunction} */
|
||||
const check = async ({ cancel }) => {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
the loaded page, so a 50-row list showed pills adding up to 10.
|
||||
-->
|
||||
<span class="v2-num">{count(totals.count)}</span> invoices ·
|
||||
<span class="v2-num">{money(totals.outstanding)}</span> outstanding
|
||||
<span class="v2-num">{money(totals.outstanding, data.org.currency)}</span> outstanding
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<a class="v2-btn v2-btn-primary" href="/invoices/new"><Plus />New invoice</a>
|
||||
|
|
@ -63,13 +63,26 @@
|
|||
<div class="v2-stats">
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
value={money(totals.overdue)}
|
||||
value={money(totals.overdue, data.org.currency)}
|
||||
tone="rust"
|
||||
detail="Chase these first"
|
||||
/>
|
||||
<StatCard label="Due this month" value={money(totals.due_this_month)} tone="clay" />
|
||||
<StatCard label="Paid this quarter" value={money(totals.paid_this_quarter)} tone="moss" />
|
||||
<StatCard label="Draft" value={money(totals.draft)} tone="slate" detail="Not sent yet" />
|
||||
<StatCard
|
||||
label="Due this month"
|
||||
value={money(totals.due_this_month, data.org.currency)}
|
||||
tone="clay"
|
||||
/>
|
||||
<StatCard
|
||||
label="Paid this quarter"
|
||||
value={money(totals.paid_this_quarter, data.org.currency)}
|
||||
tone="moss"
|
||||
/>
|
||||
<StatCard
|
||||
label="Draft"
|
||||
value={money(totals.draft, data.org.currency)}
|
||||
tone="slate"
|
||||
detail="Not sent yet"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -134,7 +147,9 @@
|
|||
<td>
|
||||
<Pill tone={INVOICE_STATUS_TONE[inv.status]}>{invoiceStatusLabel(inv.status)}</Pill>
|
||||
</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600">{money(inv.total_amount)}</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600"
|
||||
>{money(inv.total_amount, inv.currency)}</td
|
||||
>
|
||||
<td>{shortDate(inv.due_date)}</td>
|
||||
<td
|
||||
class="v2-r v2-num"
|
||||
|
|
|
|||
|
|
@ -145,9 +145,9 @@
|
|||
{#if li.detail}<div class="v2-table-secondary">{li.detail}</div>{/if}
|
||||
</td>
|
||||
<td class="v2-r v2-num">{li.quantity}</td>
|
||||
<td class="v2-r v2-num">{money(li.rate)}</td>
|
||||
<td class="v2-r v2-num">{money(li.rate, invoice.currency)}</td>
|
||||
<td class="v2-r v2-num">{li.tax_rate}%</td>
|
||||
<td class="v2-r v2-num">{money(li.amount)}</td>
|
||||
<td class="v2-r v2-num">{money(li.amount, invoice.currency)}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
|
|
@ -163,19 +163,29 @@
|
|||
<div style="display:flex;justify-content:flex-end">
|
||||
<dl class="v2-kv" style="grid-template-columns:160px 120px;width:280px">
|
||||
<dt>Subtotal</dt>
|
||||
<dd class="v2-num" style="text-align:right">{money(invoice.subtotal)}</dd>
|
||||
<dd class="v2-num" style="text-align:right">
|
||||
{money(invoice.subtotal, invoice.currency)}
|
||||
</dd>
|
||||
{#if invoice.discount_amount > 0}
|
||||
<dt>Discount</dt>
|
||||
<dd class="v2-num" style="text-align:right">−{money(invoice.discount_amount)}</dd>
|
||||
<dd class="v2-num" style="text-align:right">
|
||||
−{money(invoice.discount_amount, invoice.currency)}
|
||||
</dd>
|
||||
{/if}
|
||||
<dt>Tax</dt>
|
||||
<dd class="v2-num" style="text-align:right">{money(invoice.tax_amount)}</dd>
|
||||
<dd class="v2-num" style="text-align:right">
|
||||
{money(invoice.tax_amount, invoice.currency)}
|
||||
</dd>
|
||||
{#if invoice.shipping_amount > 0}
|
||||
<dt>Shipping</dt>
|
||||
<dd class="v2-num" style="text-align:right">{money(invoice.shipping_amount)}</dd>
|
||||
<dd class="v2-num" style="text-align:right">
|
||||
{money(invoice.shipping_amount, invoice.currency)}
|
||||
</dd>
|
||||
{/if}
|
||||
<dt>Paid</dt>
|
||||
<dd class="v2-num" style="text-align:right">{money(invoice.amount_paid)}</dd>
|
||||
<dd class="v2-num" style="text-align:right">
|
||||
{money(invoice.amount_paid, invoice.currency)}
|
||||
</dd>
|
||||
<dt style="color:var(--v2-ink);font-weight:600">Outstanding</dt>
|
||||
<dd
|
||||
class="v2-num"
|
||||
|
|
@ -183,7 +193,7 @@
|
|||
? 'var(--v2-rust)'
|
||||
: 'var(--v2-moss)'}"
|
||||
>
|
||||
{money(invoice.amount_due)}
|
||||
{money(invoice.amount_due, invoice.currency)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -205,7 +215,7 @@
|
|||
name="amount"
|
||||
class="v2-input"
|
||||
style="width:150px"
|
||||
placeholder={money(invoice.amount_due)}
|
||||
placeholder={money(invoice.amount_due, invoice.currency)}
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -213,7 +223,7 @@
|
|||
</form>
|
||||
</div>
|
||||
<p class="v2-sub" style="text-align:right;font-size:11.5px;margin-top:6px">
|
||||
Leave the amount blank to settle the full {money(invoice.amount_due)} balance.
|
||||
Leave the amount blank to settle the full {money(invoice.amount_due, invoice.currency)} balance.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
<PageHeader title="Estimates">
|
||||
{#snippet sub()}
|
||||
<span class="v2-num">{count(totals.count)}</span> estimates ·
|
||||
<span class="v2-num">{money(totals.awaiting_reply)}</span> awaiting a reply
|
||||
<span class="v2-num">{money(totals.awaiting_reply, data.org.currency)}</span> awaiting a reply
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<!-- An estimate is raised from a deal, not typed from scratch here. The
|
||||
|
|
@ -71,11 +71,15 @@
|
|||
<div class="v2-stats">
|
||||
<StatCard
|
||||
label="Accepted, not billed"
|
||||
value={money(totals.accepted_unconverted)}
|
||||
value={money(totals.accepted_unconverted, data.org.currency)}
|
||||
tone="clay"
|
||||
detail="Agreed and waiting on an invoice"
|
||||
/>
|
||||
<StatCard label="Awaiting a reply" value={money(totals.awaiting_reply)} tone="ink" />
|
||||
<StatCard
|
||||
label="Awaiting a reply"
|
||||
value={money(totals.awaiting_reply, data.org.currency)}
|
||||
tone="ink"
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiring within 7 days"
|
||||
value={count(totals.expiring_within_7d)}
|
||||
|
|
@ -152,7 +156,9 @@
|
|||
<span class="v2-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600">{money(e.total_amount)}</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600"
|
||||
>{money(e.total_amount, e.currency)}</td
|
||||
>
|
||||
<td class="v2-r" style={v?.urgent ? 'color:var(--v2-rust);font-weight:600' : ''}>
|
||||
{#if v}
|
||||
{v.text}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<PageHeader title="Recurring">
|
||||
{#snippet sub()}
|
||||
<span class="v2-num">{count(totals.active)}</span> active schedules ·
|
||||
<span class="v2-num">{money(totals.monthly_run_rate)}</span> a month
|
||||
<span class="v2-num">{money(totals.monthly_run_rate, data.org.currency)}</span> a month
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<a class="v2-btn v2-btn-primary" href="/invoices/recurring/new"><Plus />New schedule</a>
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
<div class="v2-stats">
|
||||
<StatCard
|
||||
label="Monthly run rate"
|
||||
value={money(totals.monthly_run_rate)}
|
||||
value={money(totals.monthly_run_rate, data.org.currency)}
|
||||
tone="ink"
|
||||
detail="Every active schedule, normalised to a month"
|
||||
/>
|
||||
|
|
@ -150,7 +150,9 @@
|
|||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600">{money(s.total_amount)}</td>
|
||||
<td class="v2-r v2-num" style="font-weight:600"
|
||||
>{money(s.total_amount, s.currency)}</td
|
||||
>
|
||||
<td class="v2-r">
|
||||
<div class="rec-next">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -79,16 +79,16 @@
|
|||
{:else}
|
||||
<div class="v2-pad" style="padding-top:16px;flex:none">
|
||||
<div class="v2-stats">
|
||||
<StatCard label="Invoiced" value={money(d.total_invoiced)} tone="ink" />
|
||||
<StatCard label="Invoiced" value={money(d.total_invoiced, data.org.currency)} tone="ink" />
|
||||
<StatCard
|
||||
label="Collected"
|
||||
value={money(d.total_paid)}
|
||||
value={money(d.total_paid, data.org.currency)}
|
||||
tone="moss"
|
||||
detail="{collected}% of invoiced"
|
||||
/>
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
value={money(d.overdue_amount)}
|
||||
value={money(d.overdue_amount, data.org.currency)}
|
||||
tone={d.overdue_amount > 0 ? 'rust' : 'slate'}
|
||||
detail="{count(aging.overdue_count)} invoices past their due date"
|
||||
/>
|
||||
|
|
@ -113,9 +113,10 @@
|
|||
{#each data.revenue as r (r.period)}
|
||||
<div
|
||||
class="v2-col"
|
||||
title="{monthLabel(r.period)}: {money(r.invoiced)} invoiced, {money(
|
||||
r.paid
|
||||
)} collected"
|
||||
title="{monthLabel(r.period)}: {money(
|
||||
r.invoiced,
|
||||
data.org.currency
|
||||
)} invoiced, {money(r.paid, data.org.currency)} collected"
|
||||
>
|
||||
<i class="in" style="height:{(r.invoiced / peak) * 100}%"></i>
|
||||
<i class="out" style="height:{(r.paid / peak) * 100}%"></i>
|
||||
|
|
@ -154,7 +155,7 @@
|
|||
{b.count === 1 ? 'invoice' : 'invoices'}
|
||||
</span>
|
||||
<span class="v2-num" style="margin-left:auto;font-size:13px;font-weight:600">
|
||||
{money(b.amount)}
|
||||
{money(b.amount, data.org.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="v2-bar">
|
||||
|
|
@ -175,7 +176,9 @@
|
|||
-->
|
||||
<div class="v2-aging-note">
|
||||
<span class="v2-sub">Not yet due</span>
|
||||
<span class="v2-num" style="font-size:13px">{money(aging.not_yet_due.amount)}</span>
|
||||
<span class="v2-num" style="font-size:13px"
|
||||
>{money(aging.not_yet_due.amount, data.org.currency)}</span
|
||||
>
|
||||
<span class="v2-sub" style="font-size:11px">
|
||||
across {count(aging.not_yet_due.count)} invoices, not late, not shown above
|
||||
</span>
|
||||
|
|
@ -214,7 +217,9 @@
|
|||
>
|
||||
{a.oldest_days}d
|
||||
</td>
|
||||
<td class="v2-num" style="text-align:right;font-weight:600">{money(a.amount)}</td>
|
||||
<td class="v2-num" style="text-align:right;font-weight:600"
|
||||
>{money(a.amount, data.org.currency)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@
|
|||
</td>
|
||||
<td data-m="tag"><Pill tone={LEAD_STATUS_TONE[l.status]}>{l.status}</Pill></td>
|
||||
<td class="v2-muted" data-m="hide" style="font-size:12.5px">{l.source}</td>
|
||||
<td class="v2-r v2-num">{l.opportunity_amount ? money(l.opportunity_amount) : '—'}</td
|
||||
<td class="v2-r v2-num"
|
||||
>{l.opportunity_amount ? money(l.opportunity_amount, l.currency) : '—'}</td
|
||||
>
|
||||
<!--
|
||||
`last_contacted` is the only touch the model records. Lead has
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@
|
|||
tone: 'ember',
|
||||
label: 'Needs an owner',
|
||||
text: lead.opportunity_amount
|
||||
? `Worth ${money(lead.opportunity_amount)} and nobody owns it. Assign an owner before it goes cold.`
|
||||
? `Worth ${money(lead.opportunity_amount, lead.currency)} and nobody owns it. Assign an owner before it goes cold.`
|
||||
: 'Nobody owns this lead. Assign an owner so it does not sit unworked.',
|
||||
action: 'Assign owner'
|
||||
};
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
Creates an <b style="color:var(--v2-ink)">account</b>, a
|
||||
<b style="color:var(--v2-ink)">contact</b> and one
|
||||
<b style="color:var(--v2-ink)">deal</b>{lead.opportunity_amount
|
||||
? ` worth ${money(lead.opportunity_amount)}`
|
||||
? ` worth ${money(lead.opportunity_amount, lead.currency)}`
|
||||
: ''}. The lead stays linked, and there is no endpoint that undoes it.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -458,7 +458,7 @@
|
|||
<dt>Industry</dt>
|
||||
<dd>{industryLabel(lead.industry) || '—'}</dd>
|
||||
<dt>Est. value</dt>
|
||||
<dd class="v2-num">{lead.opportunity_amount ? money(lead.opportunity_amount) : '—'}</dd>
|
||||
<dd class="v2-num">{lead.opportunity_amount ? money(lead.opportunity_amount, lead.currency) : '—'}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="v2-label v2-rail-head">Timeline</div>
|
||||
|
|
|
|||
|
|
@ -456,7 +456,7 @@
|
|||
{:else}
|
||||
<p class="v2-hint">
|
||||
{Number(form.opportunity_amount) > 0
|
||||
? money(Number(form.opportunity_amount))
|
||||
? money(Number(form.opportunity_amount), lead.currency)
|
||||
: 'What the deal would be worth if it lands.'}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@
|
|||
{:else}
|
||||
<p class="v2-hint" id="h-amount">
|
||||
{Number(form.opportunity_amount) > 0
|
||||
? money(Number(form.opportunity_amount))
|
||||
? money(Number(form.opportunity_amount), data.org.currency)
|
||||
: 'What the deal would be worth if it lands.'}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -67,18 +67,7 @@ export async function load(event) {
|
|||
// from this `.server.js` module (SvelteKit forbids that, the same
|
||||
// protection as `$lib/server/`); the toggle just needs to know which of
|
||||
// the CURRENT params survive the switch.
|
||||
boardFields: BOARD_FIELDS,
|
||||
// The currency for figures that are sums rather than one deal: the header
|
||||
// totals and the board lane subtotals. A per-deal currency cannot label a
|
||||
// sum, and `money()` would otherwise fall back to a hardcoded USD and
|
||||
// print a dollar sign over a euro figure.
|
||||
//
|
||||
// This does not make a mixed-currency sum correct. `get_totals` in
|
||||
// `opportunity_views.py` is `SUM(amount)` over every row regardless of
|
||||
// currency, so on an org running more than one the total is an addition
|
||||
// that should not have happened, and no symbol repairs it. Read from the
|
||||
// JWT's org settings, which is also where a new deal's currency comes from.
|
||||
orgCurrency: /** @type {any} */ (locals).org_settings?.default_currency || 'USD'
|
||||
boardFields: BOARD_FIELDS
|
||||
};
|
||||
|
||||
if (boardMode) {
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@
|
|||
that was only ever true under the old hardcoded ?open=true would lie
|
||||
here as soon as somebody switched presets. -->
|
||||
<span class="v2-num">{count(totals.count)}</span> deals ·
|
||||
<span class="v2-num">{money(totals.amount_sum, data.orgCurrency)}</span> ·
|
||||
<span class="v2-num">{money(totals.weighted_sum, data.orgCurrency)}</span> weighted ·
|
||||
<span class="v2-num">{money(totals.amount_sum, data.org.currency)}</span> ·
|
||||
<span class="v2-num">{money(totals.weighted_sum, data.org.currency)}</span> weighted ·
|
||||
<span class="v2-num" style="color:var(--v2-rust)">{totals.stalled_count}</span> stalled
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
<section class="v2-lane">
|
||||
<div class="v2-lane-head">
|
||||
<span class="v2-label">{STAGE_LABEL[lane.stage]}</span>
|
||||
<span class="v2-num">{count(lane.count)} · {money(lane.sum, data.orgCurrency)}</span>
|
||||
<span class="v2-num">{count(lane.count)} · {money(lane.sum, data.org.currency)}</span>
|
||||
</div>
|
||||
<div class="v2-lane-body">
|
||||
{#if lane.truncated}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,11 @@ import { EDITABLE_FIELDS, createDeal, getDealFormOptions } from '$lib/server/v2/
|
|||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load(event) {
|
||||
return {
|
||||
...(await getDealFormOptions(event)),
|
||||
// The currency this deal will be created in. The form has no currency
|
||||
// field, so the serializer stamps the org default
|
||||
// (`OpportunityCreateSerializer.create`), and the hint under the amount
|
||||
// used to say "USD" whatever that default actually was.
|
||||
orgCurrency: /** @type {any} */ (event.locals).org_settings?.default_currency || 'USD'
|
||||
};
|
||||
// The currency hint under the amount comes from the shell (`data.org.currency`
|
||||
// via `(app)/+layout.server.js`). It is the currency this deal will be created
|
||||
// in: the form has no currency field, so the serializer stamps the org default
|
||||
// (`OpportunityCreateSerializer.create`).
|
||||
return await getDealFormOptions(event);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').Actions} */
|
||||
|
|
|
|||
|
|
@ -211,8 +211,8 @@
|
|||
{:else}
|
||||
<p class="v2-hint" id="h-amount">
|
||||
{Number(form.amount) > 0
|
||||
? money(Number(form.amount), data.orgCurrency)
|
||||
: data.orgCurrency}
|
||||
? money(Number(form.amount), data.org.currency)
|
||||
: data.org.currency}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
/>
|
||||
<StatCard
|
||||
label="Billable value"
|
||||
value={money(billableValue)}
|
||||
value={money(billableValue, data.org.currency)}
|
||||
tone="slate"
|
||||
detail="At the rate saved on each entry"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
A comprehensive design guide for the BottleCRM mobile app, inspired by modern CRM apps like HubSpot.
|
||||
|
||||
> **This is the authoritative design reference for the Flutter app.** Change a
|
||||
> colour or a spacing value here. There is a second, similarly named file at
|
||||
> [`docs/DESIGN-SYSTEM.md`](./docs/DESIGN-SYSTEM.md): that one is the original
|
||||
> pre-build spec, kept for its data models and screen inventory. The two agree
|
||||
> on the values they share; where they ever diverge, this file wins.
|
||||
>
|
||||
> A third file, `Django-CRM/DESIGN_SYSTEM.md` at the repo root, is the **web**
|
||||
> design system. It describes CSS variables and does not apply here.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
# CRM Mobile App Design System
|
||||
|
||||
A comprehensive design system document for implementing the SalesPro CRM application in Flutter. This document serves as the single source of truth for colors, typography, spacing, components, and interaction patterns.
|
||||
The design system the app was originally built from, written under the working
|
||||
name "SalesPro CRM".
|
||||
|
||||
> **For colors, typography and spacing, [`../DESIGN_SYSTEM.md`](../DESIGN_SYSTEM.md)
|
||||
> is authoritative.** The two agree on the values they share, including the
|
||||
> `#2563EB` primary, so nothing here contradicts it. This file is kept for what
|
||||
> the other one does not carry: data models, navigation structure, screen
|
||||
> inventory, and the interaction and motion patterns below.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
# SalesPro CRM - Flutter Implementation Documentation
|
||||
# BottleCRM Mobile - Original Implementation Spec
|
||||
|
||||
This documentation provides comprehensive specifications for implementing the SalesPro CRM mobile application in Flutter. The specifications are derived from a fully functional SvelteKit prototype.
|
||||
> **This tree is the brief the app was built from, not a description of the app
|
||||
> as it stands.** It was written against a SvelteKit prototype under the working
|
||||
> name "SalesPro CRM", and the app has moved on: it signs in with Google and
|
||||
> magic links rather than a password, so `03-forgot-password.md` and
|
||||
> `04-onboarding.md` describe screens that were never built. Read it for intent
|
||||
> and history.
|
||||
>
|
||||
> **For design tokens, [`../DESIGN_SYSTEM.md`](../DESIGN_SYSTEM.md) is
|
||||
> authoritative.** It is the file the workspace `CLAUDE.md` points at, and the
|
||||
> one to change when a colour or spacing value changes.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Read the Design System** - Start with [DESIGN-SYSTEM.md](./DESIGN-SYSTEM.md) for colors, typography, spacing, and overall architecture
|
||||
1. **Read the Design System** - [`../DESIGN_SYSTEM.md`](../DESIGN_SYSTEM.md) carries the current colors, typography, and spacing. [DESIGN-SYSTEM.md](./DESIGN-SYSTEM.md) in this folder adds the original data models, navigation structure, and screen inventory
|
||||
2. **Review Components** - Check [components/ui-components.md](./components/ui-components.md) for reusable widget specifications
|
||||
3. **Implement Screens** - Follow the route documentation in sequential order
|
||||
|
||||
|
|
@ -18,7 +27,8 @@ This documentation provides comprehensive specifications for implementing the Sa
|
|||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [DESIGN-SYSTEM.md](./DESIGN-SYSTEM.md) | **Start here** - Complete design system with colors, typography, spacing, data models, and implementation checklist |
|
||||
| [../DESIGN_SYSTEM.md](../DESIGN_SYSTEM.md) | **Authoritative** - the colors, typography, spacing and component rules the app is actually built to |
|
||||
| [DESIGN-SYSTEM.md](./DESIGN-SYSTEM.md) | The original design system, kept for its data models, navigation structure, and screen inventory. Where its tokens disagree with the file above, the file above wins |
|
||||
| [components/ui-components.md](./components/ui-components.md) | Detailed specifications for all reusable UI components |
|
||||
|
||||
### Authentication Screens
|
||||
|
|
|
|||
|
|
@ -67,8 +67,6 @@ class AppRoutes {
|
|||
static const String taskEdit = '/tasks/:id/edit';
|
||||
static const String more = '/more';
|
||||
static const String profile = '/more/profile';
|
||||
static const String notifications = '/more/notifications';
|
||||
static const String team = '/more/team';
|
||||
|
||||
// Knowledge base
|
||||
static const String solutions = '/solutions';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
import '../../core/theme/theme.dart';
|
||||
import '../../data/models/dashboard_data.dart';
|
||||
import '../../data/models/deal.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/dashboard_provider.dart';
|
||||
import '../../routes/app_router.dart';
|
||||
|
|
@ -25,16 +26,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
final authState = ref.watch(authProvider);
|
||||
final user = authState.user;
|
||||
final greetingName = _friendlyFirstName(user?.name, user?.email);
|
||||
final currencySymbol =
|
||||
authState.selectedOrganization?.currencySymbol ?? '\$';
|
||||
final currencyFormat = NumberFormat.currency(
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: 0,
|
||||
);
|
||||
final compactCurrencyFormat = NumberFormat.compactCurrency(
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: 1,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surfaceDim,
|
||||
|
|
@ -76,13 +67,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
),
|
||||
|
||||
// Content
|
||||
SliverToBoxAdapter(
|
||||
child: _buildContent(
|
||||
dashboardAsync,
|
||||
currencyFormat,
|
||||
compactCurrencyFormat,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildContent(dashboardAsync)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -90,11 +75,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(
|
||||
AsyncValue<DashboardData> dashboardAsync,
|
||||
NumberFormat currencyFormat,
|
||||
NumberFormat compactCurrencyFormat,
|
||||
) {
|
||||
Widget _buildContent(AsyncValue<DashboardData> dashboardAsync) {
|
||||
// Stale-while-loading: keep showing prior data during refresh.
|
||||
final data = dashboardAsync.value;
|
||||
|
||||
|
|
@ -130,19 +111,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
}
|
||||
|
||||
final resolved = data ?? const DashboardData();
|
||||
return _buildBody(resolved, currencyFormat, compactCurrencyFormat);
|
||||
return _buildBody(resolved);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
DashboardData data,
|
||||
NumberFormat currencyFormat,
|
||||
NumberFormat compactCurrencyFormat,
|
||||
) {
|
||||
Widget _buildBody(DashboardData data) {
|
||||
// The currency belongs to the payload, not to the stored org record: no
|
||||
// endpoint returns `currency_symbol`, so reading it there gave null and
|
||||
// printed every amount with a dollar sign. `/api/dashboard/` reports the
|
||||
// currency it priced these totals in, alongside them.
|
||||
final currencyFormat = NumberFormat.compactCurrency(
|
||||
symbol: Currency.fromString(data.revenueMetrics.currency).symbol,
|
||||
decimalDigits: 1,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// KPI Cards
|
||||
_buildKpiSection(data, compactCurrencyFormat),
|
||||
_buildKpiSection(data, currencyFormat),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -154,7 +140,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
|
||||
// Pipeline Overview
|
||||
if (data.pipelineByStage.isNotEmpty) ...[
|
||||
_buildPipelineSection(data.pipelineByStage, compactCurrencyFormat),
|
||||
_buildPipelineSection(
|
||||
data.pipelineByStage,
|
||||
currencyFormat,
|
||||
data.revenueMetrics.otherCurrencyCount,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
|
|
@ -243,6 +233,21 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
icon: LucideIcons.target,
|
||||
color: AppColors.purple500,
|
||||
),
|
||||
// Neither of these two summarises a list the app can open: one is a
|
||||
// month-and-stage slice, the other a forecast. Like Conversion, they
|
||||
// stay plain rather than link somewhere that shows something else.
|
||||
_KpiCard(
|
||||
title: 'Won This Month',
|
||||
value: currencyFormat.format(data.revenueMetrics.wonThisMonth),
|
||||
icon: LucideIcons.trophy,
|
||||
color: AppColors.success500,
|
||||
),
|
||||
_KpiCard(
|
||||
title: 'Weighted',
|
||||
value: currencyFormat.format(data.revenueMetrics.weightedPipeline),
|
||||
icon: LucideIcons.scale,
|
||||
color: AppColors.primary400,
|
||||
),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
|
|
@ -328,10 +333,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
Widget _buildPipelineSection(
|
||||
List<PipelineStage> stages,
|
||||
NumberFormat currencyFormat,
|
||||
int otherCurrencyCount,
|
||||
) {
|
||||
// Only show active pipeline stages (not closed)
|
||||
// Only show active pipeline stages (not closed). Selecting on the deal
|
||||
// count rather than the value, because the server prices a stage in the
|
||||
// org's currency alone: a stage whose deals are all in another currency
|
||||
// arrives worth zero and used to disappear from the chart entirely.
|
||||
final activeStages = stages
|
||||
.where((s) => !s.code.contains('CLOSED') && s.value > 0)
|
||||
.where((s) => !s.code.contains('CLOSED') && s.count > 0)
|
||||
.toList();
|
||||
|
||||
if (activeStages.isEmpty) {
|
||||
|
|
@ -396,7 +405,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
SizedBox(
|
||||
width: 70,
|
||||
child: Text(
|
||||
currencyFormat.format(stage.value),
|
||||
// An unpriced stage reports what it does hold. The
|
||||
// alternative was a confident zero next to deals that
|
||||
// exist.
|
||||
stage.value > 0
|
||||
? currencyFormat.format(stage.value)
|
||||
: '${stage.count} '
|
||||
'${stage.count == 1 ? 'deal' : 'deals'}',
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 11,
|
||||
|
|
@ -408,6 +423,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||
),
|
||||
);
|
||||
}),
|
||||
if (otherCurrencyCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
otherCurrencyCount == 1
|
||||
? '1 deal in another currency is not counted in these '
|
||||
'totals'
|
||||
: '$otherCurrencyCount deals in other currencies are not '
|
||||
'counted in these totals',
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
174
mobile/test/screens/dashboard/dashboard_currency_test.dart
Normal file
174
mobile/test/screens/dashboard/dashboard_currency_test.dart
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import 'package:bottle_crm/core/theme/theme.dart';
|
||||
import 'package:bottle_crm/data/models/auth_response.dart';
|
||||
import 'package:bottle_crm/data/models/dashboard_data.dart';
|
||||
import 'package:bottle_crm/providers/auth_provider.dart';
|
||||
import 'package:bottle_crm/providers/dashboard_provider.dart';
|
||||
import 'package:bottle_crm/screens/dashboard/dashboard_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The dashboard took its currency symbol from the stored org record. Sign-in
|
||||
/// and the org switch returned `{id, name, role}` and nothing else, so
|
||||
/// `currencySymbol` was always null and every amount fell back to a dollar
|
||||
/// sign. Those endpoints now send the currency (see
|
||||
/// `TestOrgPayloadCarriesCurrency` on the backend), but the dashboard reads it
|
||||
/// from `/api/dashboard/` instead, which reports the currency it actually
|
||||
/// priced these totals in. The fake org below therefore carries no symbol on
|
||||
/// purpose: the screen must not need one.
|
||||
///
|
||||
/// The same block reports how many deals were left out of those totals for
|
||||
/// being in another currency. Ignoring it made a stage of foreign-currency
|
||||
/// deals vanish from the chart, because the chart filtered on value and the
|
||||
/// server had already zeroed it.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Widget host(DashboardData data) => ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _FakeAuth()),
|
||||
dashboardProvider.overrideWith(() => _FakeDashboard(data)),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.light, home: const DashboardScreen()),
|
||||
);
|
||||
|
||||
testWidgets('amounts print the org currency, not a dollar sign', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
const DashboardData(
|
||||
revenueMetrics: RevenueMetrics(
|
||||
pipelineValue: 1463538,
|
||||
currency: 'EUR',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('€'), findsWidgets);
|
||||
expect(find.textContaining(r'$'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a stage holding only foreign-currency deals still appears', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
const DashboardData(
|
||||
pipelineByStage: [
|
||||
PipelineStage(
|
||||
code: 'PROPOSAL',
|
||||
label: 'Proposal',
|
||||
count: 2,
|
||||
value: 0,
|
||||
),
|
||||
],
|
||||
revenueMetrics: RevenueMetrics(currency: 'USD'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The stage is on the chart, and reports the deals it holds rather than a
|
||||
// priced zero it cannot back up.
|
||||
expect(find.text('Proposal'), findsOneWidget);
|
||||
expect(find.text('2 deals'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deals left out of the totals are named', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
const DashboardData(
|
||||
pipelineByStage: [
|
||||
PipelineStage(
|
||||
code: 'PROPOSAL',
|
||||
label: 'Proposal',
|
||||
count: 4,
|
||||
value: 90000,
|
||||
),
|
||||
],
|
||||
revenueMetrics: RevenueMetrics(
|
||||
pipelineValue: 90000,
|
||||
currency: 'USD',
|
||||
otherCurrencyCount: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('3 deals in other currencies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an org with one currency gets no exclusion note', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
const DashboardData(
|
||||
pipelineByStage: [
|
||||
PipelineStage(
|
||||
code: 'PROPOSAL',
|
||||
label: 'Proposal',
|
||||
count: 4,
|
||||
value: 90000,
|
||||
),
|
||||
],
|
||||
revenueMetrics: RevenueMetrics(pipelineValue: 90000),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('other currencies'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the month result and the weighted forecast are on the board', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
const DashboardData(
|
||||
revenueMetrics: RevenueMetrics(
|
||||
pipelineValue: 200000,
|
||||
weightedPipeline: 84000,
|
||||
wonThisMonth: 40000,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Won This Month'), findsOneWidget);
|
||||
expect(find.text('Weighted'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeDashboard extends DashboardNotifier {
|
||||
_FakeDashboard(this.data);
|
||||
|
||||
final DashboardData data;
|
||||
|
||||
@override
|
||||
Future<DashboardData> build() async => data;
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {}
|
||||
}
|
||||
|
||||
/// Deliberately bare. If any assertion here starts depending on the org
|
||||
/// record, this is the fake that will catch it.
|
||||
class _FakeAuth extends AuthNotifier {
|
||||
@override
|
||||
AuthState build() {
|
||||
const org = Organization(id: 'org-1', name: 'Test Org');
|
||||
return AuthState(
|
||||
user: const AuthUser(id: 'user-1', email: 'user@example.com'),
|
||||
organizations: [org],
|
||||
selectedOrganization: org,
|
||||
isAuthenticated: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
61
mobile/test/screens/deals/deal_form_currency_test.dart
Normal file
61
mobile/test/screens/deals/deal_form_currency_test.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:bottle_crm/core/theme/theme.dart';
|
||||
import 'package:bottle_crm/data/models/auth_response.dart';
|
||||
import 'package:bottle_crm/providers/auth_provider.dart';
|
||||
import 'package:bottle_crm/screens/deals/deal_form_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A new deal takes its currency from the org, and the org record the client
|
||||
/// stores was arriving without one, so every deal an org booked defaulted to
|
||||
/// dollars however it actually trades. The client half was already right; it
|
||||
/// was reading a field no endpoint sent.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Widget host(String? orgCurrency) => ProviderScope(
|
||||
overrides: [authProvider.overrideWith(() => _FakeAuth(orgCurrency))],
|
||||
child: MaterialApp(theme: AppTheme.light, home: const DealFormScreen()),
|
||||
);
|
||||
|
||||
testWidgets('a new deal opens in the currency the org trades in', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(host('EUR'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('EUR'), findsOneWidget);
|
||||
expect(find.text('€'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an org that never set one still gets a usable default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(host(null));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('USD'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAuth extends AuthNotifier {
|
||||
_FakeAuth(this.orgCurrency);
|
||||
|
||||
final String? orgCurrency;
|
||||
|
||||
@override
|
||||
AuthState build() {
|
||||
final org = Organization(
|
||||
id: 'org-1',
|
||||
name: 'Test Org',
|
||||
role: 'ADMIN',
|
||||
defaultCurrency: orgCurrency,
|
||||
);
|
||||
return AuthState(
|
||||
user: const AuthUser(id: 'user-1', email: 'user@example.com'),
|
||||
organizations: [org],
|
||||
selectedOrganization: org,
|
||||
isAuthenticated: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue