clean up
This commit is contained in:
parent
8e45236d5a
commit
601ca1da48
166 changed files with 27 additions and 8743 deletions
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -64,8 +64,8 @@ jobs:
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: uv sync --frozen
|
run: uv sync --frozen
|
||||||
|
|
||||||
# The suite runs with --no-migrations for speed, so nothing else would
|
# Running the migrations cannot notice a model change that has no
|
||||||
# catch a model change that is missing its migration.
|
# migration yet, so this checks for one explicitly.
|
||||||
- name: Check for missing migrations
|
- name: Check for missing migrations
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ Frontend:
|
||||||
- Formatting: `#lib/v2/format.js` (`money(n, currency)`, which defaults to USD, so pass `data.org.currency`; `shortDate`, `longDate`, `relativeTime`). The org's "today": `todayIn(tz)` from `#lib/v2/dates.js` with `data.org.timezone`.
|
- Formatting: `#lib/v2/format.js` (`money(n, currency)`, which defaults to USD, so pass `data.org.currency`; `shortDate`, `longDate`, `relativeTime`). The org's "today": `todayIn(tz)` from `#lib/v2/dates.js` with `data.org.timezone`.
|
||||||
- Admin: `isOrgAdmin` from `#lib/admin.js`. Labels and tones: `#lib/v2/enums.js`. Classes: `cn` from `#lib/utils.js`. DRF errors to a sentence: `readableError` from `#lib/server/v2/form-errors.js`.
|
- Admin: `isOrgAdmin` from `#lib/admin.js`. Labels and tones: `#lib/v2/enums.js`. Classes: `cn` from `#lib/utils.js`. DRF errors to a sentence: `readableError` from `#lib/server/v2/form-errors.js`.
|
||||||
- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. @mentions: `MentionTextarea` and `MentionBody` in `#lib/v2/components/`, fed by `mentionPeople()` from `#lib/v2/mentions.js`.
|
- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. @mentions: `MentionTextarea` and `MentionBody` in `#lib/v2/components/`, fed by `mentionPeople()` from `#lib/v2/mentions.js`.
|
||||||
- Components: `#lib/v2/components/<Name>.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). Many shadcn components in `components/ui/` and everything in `components/layout`, `components/dashboard` and `components/users` have no importers. Find a live caller before building on any of them.
|
- Components: `#lib/v2/components/<Name>.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -507,8 +507,7 @@ class TestPublicCsatResolvesOrgFirst:
|
||||||
def test_public_survey_answers_under_enforced_rls(client):
|
def test_public_survey_answers_under_enforced_rls(client):
|
||||||
"""The anonymous survey GET and POST work under a role that enforces RLS.
|
"""The anonymous survey GET and POST work under a role that enforces RLS.
|
||||||
|
|
||||||
Runs only against PostgreSQL as a non-superuser (``RLS_ENFORCE=1
|
Runs only against PostgreSQL as a non-superuser (``--ds=crm.test_settings_postgres``): a superuser bypasses RLS, and the first
|
||||||
--ds=crm.test_settings_postgres``): a superuser bypasses RLS, and the first
|
|
||||||
assertion below says so rather than passing vacuously.
|
assertion below says so rather than passing vacuously.
|
||||||
"""
|
"""
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,7 @@ class TestBackfill:
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Under a role RLS binds (RLS_ENFORCE run, --ds=crm.test_settings_postgres)
|
# Under a role RLS binds (--ds=crm.test_settings_postgres)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,33 +118,6 @@ class BaseModel(AuditModel):
|
||||||
return str(self.id)
|
return str(self.id)
|
||||||
|
|
||||||
|
|
||||||
class OrgScopedQuerySet(models.QuerySet):
|
|
||||||
"""QuerySet with automatic organization filtering support"""
|
|
||||||
|
|
||||||
def for_org(self, org):
|
|
||||||
"""Filter queryset by organization"""
|
|
||||||
return self.filter(org=org)
|
|
||||||
|
|
||||||
def for_request(self, request):
|
|
||||||
"""Filter queryset by organization from request"""
|
|
||||||
if hasattr(request, "profile") and request.profile:
|
|
||||||
return self.filter(org=request.profile.org)
|
|
||||||
return self.none()
|
|
||||||
|
|
||||||
|
|
||||||
class OrgScopedManager(models.Manager):
|
|
||||||
"""Manager that uses OrgScopedQuerySet"""
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return OrgScopedQuerySet(self.model, using=self._db)
|
|
||||||
|
|
||||||
def for_org(self, org):
|
|
||||||
return self.get_queryset().for_org(org)
|
|
||||||
|
|
||||||
def for_request(self, request):
|
|
||||||
return self.get_queryset().for_request(request)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOrgModel(BaseModel):
|
class BaseOrgModel(BaseModel):
|
||||||
"""
|
"""
|
||||||
Base model for all organization-scoped entities.
|
Base model for all organization-scoped entities.
|
||||||
|
|
@ -153,7 +126,6 @@ class BaseOrgModel(BaseModel):
|
||||||
This ensures:
|
This ensures:
|
||||||
- Required org field (no null values)
|
- Required org field (no null values)
|
||||||
- Per-org indexes for performance
|
- Per-org indexes for performance
|
||||||
- OrgScopedManager for easier filtering
|
|
||||||
|
|
||||||
RLS Protection:
|
RLS Protection:
|
||||||
---------------
|
---------------
|
||||||
|
|
@ -174,8 +146,6 @@ class BaseOrgModel(BaseModel):
|
||||||
help_text="Organization this record belongs to",
|
help_text="Organization this record belongs to",
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = OrgScopedManager()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
indexes = [
|
indexes = [
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class GetProfileAndOrg:
|
||||||
# another's without any view knowing about it.
|
# another's without any view knowing about it.
|
||||||
#
|
#
|
||||||
# The deactivate is in a `finally` for the same reason the RLS reset is
|
# The deactivate is in a `finally` for the same reason the RLS reset is
|
||||||
# (see `rls_context.SetOrgContext.__call__`): the active timezone is a
|
# (see `rls_context.RequireOrgContext.__call__`): the active timezone is a
|
||||||
# thread-local, worker threads are reused, and a request that raised
|
# thread-local, worker threads are reused, and a request that raised
|
||||||
# after activating would hand the next tenant its own day boundary.
|
# after activating would hand the next tenant its own day boundary.
|
||||||
activate_org_timezone(getattr(request, "org", None))
|
activate_org_timezone(getattr(request, "org", None))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Usage in settings.py:
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
...
|
...
|
||||||
'common.middleware.get_company.GetProfileAndOrg',
|
'common.middleware.get_company.GetProfileAndOrg',
|
||||||
'common.middleware.rls_context.SetOrgContext', # After GetProfileAndOrg
|
'common.middleware.rls_context.RequireOrgContext', # After GetProfileAndOrg
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
|
|
@ -27,78 +27,10 @@ from django.http import JsonResponse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SetOrgContext:
|
|
||||||
"""
|
|
||||||
Middleware to set PostgreSQL session variable for Row-Level Security.
|
|
||||||
|
|
||||||
This sets `app.current_org` to the user's organization ID, which is
|
|
||||||
used by RLS policies to automatically filter data at the database level.
|
|
||||||
|
|
||||||
Security: This provides defense-in-depth. Even if application code
|
|
||||||
forgets to filter by org, the database will enforce isolation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, get_response):
|
|
||||||
self.get_response = get_response
|
|
||||||
|
|
||||||
def __call__(self, request):
|
|
||||||
# Set org context before processing request
|
|
||||||
self._set_org_context(request)
|
|
||||||
|
|
||||||
# The reset MUST run even when the view raises. `app.current_org` is
|
|
||||||
# set at SESSION scope, so it outlives the statement and the
|
|
||||||
# transaction; it is cleared only because we clear it. Without the
|
|
||||||
# finally, an exception anywhere downstream hands the connection back
|
|
||||||
# still carrying this tenant's org id, and the next request to reuse
|
|
||||||
# that connection inherits it. That is a cross-tenant read.
|
|
||||||
try:
|
|
||||||
return self.get_response(request)
|
|
||||||
finally:
|
|
||||||
self._reset_org_context()
|
|
||||||
|
|
||||||
def _set_org_context(self, request):
|
|
||||||
"""
|
|
||||||
Set the PostgreSQL session variable for RLS.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Django request object with profile attached
|
|
||||||
"""
|
|
||||||
if not hasattr(request, "org") or request.org is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
org_id = str(request.org.id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
# Set the session variable (is_local=false for session scope)
|
|
||||||
# Required because Django uses autocommit mode by default
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT set_config('app.current_org', %s, false)", [org_id]
|
|
||||||
)
|
|
||||||
logger.debug("Set RLS context: app.current_org = %s", org_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# RLS might not be configured - log but don't fail
|
|
||||||
logger.debug("Could not set RLS context: %s", e)
|
|
||||||
|
|
||||||
def _reset_org_context(self):
|
|
||||||
"""
|
|
||||||
Reset the PostgreSQL session variable after request.
|
|
||||||
Critical to prevent context leakage between requests on pooled connections.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute("SELECT set_config('app.current_org', '', false)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RequireOrgContext:
|
class RequireOrgContext:
|
||||||
"""
|
"""
|
||||||
Stricter middleware that fails if org context is not set.
|
Sets `app.current_org` for the request and refuses a request with no org
|
||||||
|
context, except on the exempt paths below.
|
||||||
Use this instead of SetOrgContext when you want to ensure
|
|
||||||
all requests have proper org context (after RLS is fully enabled).
|
|
||||||
|
|
||||||
Usage in settings.py:
|
Usage in settings.py:
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
@ -263,7 +195,7 @@ class RequireOrgContext:
|
||||||
# Set org context
|
# Set org context
|
||||||
self._set_org_context(request)
|
self._set_org_context(request)
|
||||||
|
|
||||||
# See SetOrgContext.__call__: the reset is in a finally because
|
# The reset is in a finally because
|
||||||
# `app.current_org` is SESSION-scoped and survives an exception. On a
|
# `app.current_org` is SESSION-scoped and survives an exception. On a
|
||||||
# pooled or otherwise reused connection, skipping it leaks this
|
# pooled or otherwise reused connection, skipping it leaks this
|
||||||
# tenant's context to whoever borrows the connection next.
|
# tenant's context to whoever borrows the connection next.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ Covers:
|
||||||
- SessionToken.revoke() and cleanup_expired()
|
- SessionToken.revoke() and cleanup_expired()
|
||||||
- Activity.created_on_arrow property
|
- Activity.created_on_arrow property
|
||||||
- AssignableMixin properties (get_team_users, etc.)
|
- AssignableMixin properties (get_team_users, etc.)
|
||||||
- OrgScopedQuerySet and OrgScopedManager methods
|
|
||||||
|
|
||||||
Run with: pytest common/tests/test_models.py -v
|
Run with: pytest common/tests/test_models.py -v
|
||||||
"""
|
"""
|
||||||
|
|
@ -265,76 +264,3 @@ class TestAssignableMixin:
|
||||||
assert account.get_team_users.count() == 0
|
assert account.get_team_users.count() == 0
|
||||||
assert account.get_team_and_assigned_users.count() == 0
|
assert account.get_team_and_assigned_users.count() == 0
|
||||||
assert account.get_assigned_users_not_in_teams.count() == 0
|
assert account.get_assigned_users_not_in_teams.count() == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# OrgScopedQuerySet and OrgScopedManager
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestOrgScopedManager:
|
|
||||||
"""Test OrgScopedQuerySet.for_org() and for_request() via a model that uses them."""
|
|
||||||
|
|
||||||
def test_org_scoped_classes_instantiate(self, org_a, org_b):
|
|
||||||
"""OrgScopedQuerySet and OrgScopedManager can be instantiated."""
|
|
||||||
from common.base import OrgScopedManager, OrgScopedQuerySet
|
|
||||||
|
|
||||||
_qs = OrgScopedQuerySet(model=Activity)
|
|
||||||
manager = OrgScopedManager()
|
|
||||||
assert manager is not None
|
|
||||||
|
|
||||||
def test_for_request_with_profile(self, org_a, admin_profile):
|
|
||||||
"""for_request should filter by org from request.profile."""
|
|
||||||
from common.base import OrgScopedQuerySet
|
|
||||||
|
|
||||||
# Create activities in different orgs
|
|
||||||
Activity.objects.create(
|
|
||||||
user=admin_profile,
|
|
||||||
action="CREATE",
|
|
||||||
entity_type="Account",
|
|
||||||
entity_id=uuid.uuid4(),
|
|
||||||
org=org_a,
|
|
||||||
)
|
|
||||||
|
|
||||||
request = MagicMock()
|
|
||||||
request.profile = admin_profile
|
|
||||||
|
|
||||||
# Test OrgScopedQuerySet.for_request directly
|
|
||||||
qs = OrgScopedQuerySet(model=Activity, using="default")
|
|
||||||
result = qs.for_request(request)
|
|
||||||
assert result.count() == 1
|
|
||||||
|
|
||||||
def test_for_request_without_profile(self):
|
|
||||||
"""for_request without profile should return empty queryset."""
|
|
||||||
from common.base import OrgScopedQuerySet
|
|
||||||
|
|
||||||
request = MagicMock(spec=[]) # no profile attribute
|
|
||||||
|
|
||||||
qs = OrgScopedQuerySet(model=Activity, using="default")
|
|
||||||
result = qs.for_request(request)
|
|
||||||
assert result.count() == 0
|
|
||||||
|
|
||||||
def test_for_org(self, org_a, org_b, admin_profile):
|
|
||||||
"""for_org should filter activities by specified org."""
|
|
||||||
from common.base import OrgScopedQuerySet
|
|
||||||
|
|
||||||
Activity.objects.create(
|
|
||||||
user=admin_profile,
|
|
||||||
action="CREATE",
|
|
||||||
entity_type="Account",
|
|
||||||
entity_id=uuid.uuid4(),
|
|
||||||
org=org_a,
|
|
||||||
)
|
|
||||||
Activity.objects.create(
|
|
||||||
user=admin_profile,
|
|
||||||
action="UPDATE",
|
|
||||||
entity_type="Lead",
|
|
||||||
entity_id=uuid.uuid4(),
|
|
||||||
org=org_b,
|
|
||||||
)
|
|
||||||
|
|
||||||
qs = OrgScopedQuerySet(model=Activity, using="default")
|
|
||||||
result = qs.for_org(org_a)
|
|
||||||
assert result.count() == 1
|
|
||||||
assert result.first().org == org_a
|
|
||||||
|
|
|
||||||
|
|
@ -314,36 +314,6 @@ class TestBaseOrgModel(TestCase):
|
||||||
self.assertIsNotNone(team.id)
|
self.assertIsNotNone(team.id)
|
||||||
|
|
||||||
|
|
||||||
class TestOrgScopedQuerySet(MultiTenancyBaseTestCase):
|
|
||||||
"""Test OrgScopedQuerySet helper methods"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
# Create teams in each org
|
|
||||||
self.team_a = Teams.objects.create(
|
|
||||||
name="Team A", description="In Org A", org=self.org_a
|
|
||||||
)
|
|
||||||
self.team_b = Teams.objects.create(
|
|
||||||
name="Team B", description="In Org B", org=self.org_b
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_for_org_filters_correctly(self):
|
|
||||||
"""for_org() should filter by organization"""
|
|
||||||
teams = Teams.objects.filter(org=self.org_a)
|
|
||||||
self.assertEqual(teams.count(), 1)
|
|
||||||
self.assertEqual(teams.first().name, "Team A")
|
|
||||||
|
|
||||||
def test_for_request_uses_profile_org(self):
|
|
||||||
"""for_request() should use org from request.profile"""
|
|
||||||
# Create mock request
|
|
||||||
request = MagicMock()
|
|
||||||
request.profile = self.profile_a
|
|
||||||
|
|
||||||
# This would be used with OrgScopedManager
|
|
||||||
# Teams.objects.for_request(request)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNullOrgPrevention(MultiTenancyBaseTestCase):
|
class TestNullOrgPrevention(MultiTenancyBaseTestCase):
|
||||||
"""Test that models properly handle org field"""
|
"""Test that models properly handle org field"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ a real PostgreSQL with a non-superuser role.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from common.middleware.rls_context import RequireOrgContext, SetOrgContext
|
from common.middleware.rls_context import RequireOrgContext
|
||||||
|
|
||||||
|
|
||||||
class _Boom(Exception):
|
class _Boom(Exception):
|
||||||
|
|
@ -44,16 +44,14 @@ def _instrument(middleware_cls, get_response):
|
||||||
return mw, calls
|
return mw, calls
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("middleware_cls", [SetOrgContext, RequireOrgContext])
|
def test_reset_runs_on_the_happy_path():
|
||||||
def test_reset_runs_on_the_happy_path(middleware_cls):
|
mw, calls = _instrument(RequireOrgContext, lambda request: "response")
|
||||||
mw, calls = _instrument(middleware_cls, lambda request: "response")
|
|
||||||
|
|
||||||
assert mw(_FakeRequest()) == "response"
|
assert mw(_FakeRequest()) == "response"
|
||||||
assert calls == ["set", "reset"]
|
assert calls == ["set", "reset"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("middleware_cls", [SetOrgContext, RequireOrgContext])
|
def test_reset_runs_when_the_view_raises():
|
||||||
def test_reset_runs_when_the_view_raises(middleware_cls):
|
|
||||||
"""The regression this file exists for.
|
"""The regression this file exists for.
|
||||||
|
|
||||||
Without the try/finally the middleware returned early on the exception and
|
Without the try/finally the middleware returned early on the exception and
|
||||||
|
|
@ -63,7 +61,7 @@ def test_reset_runs_when_the_view_raises(middleware_cls):
|
||||||
def boom(request):
|
def boom(request):
|
||||||
raise _Boom("view exploded")
|
raise _Boom("view exploded")
|
||||||
|
|
||||||
mw, calls = _instrument(middleware_cls, boom)
|
mw, calls = _instrument(RequireOrgContext, boom)
|
||||||
|
|
||||||
with pytest.raises(_Boom):
|
with pytest.raises(_Boom):
|
||||||
mw(_FakeRequest())
|
mw(_FakeRequest())
|
||||||
|
|
@ -74,14 +72,13 @@ def test_reset_runs_when_the_view_raises(middleware_cls):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("middleware_cls", [SetOrgContext, RequireOrgContext])
|
def test_the_exception_still_propagates():
|
||||||
def test_the_exception_still_propagates(middleware_cls):
|
|
||||||
"""The finally must not swallow the error into a silent 200."""
|
"""The finally must not swallow the error into a silent 200."""
|
||||||
|
|
||||||
def boom(request):
|
def boom(request):
|
||||||
raise _Boom("view exploded")
|
raise _Boom("view exploded")
|
||||||
|
|
||||||
mw, _ = _instrument(middleware_cls, boom)
|
mw, _ = _instrument(RequireOrgContext, boom)
|
||||||
|
|
||||||
with pytest.raises(_Boom, match="view exploded"):
|
with pytest.raises(_Boom, match="view exploded"):
|
||||||
mw(_FakeRequest())
|
mw(_FakeRequest())
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import pytz
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
INDCHOICES = (
|
INDCHOICES = (
|
||||||
|
|
@ -423,14 +422,6 @@ CURRENCY_SYMBOLS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def convert_to_custom_timezone(custom_date, custom_timezone, to_utc=False):
|
|
||||||
user_time_zone = pytz.timezone(custom_timezone)
|
|
||||||
if to_utc:
|
|
||||||
custom_date = user_time_zone.localize(custom_date.replace(tzinfo=None))
|
|
||||||
user_time_zone = pytz.UTC
|
|
||||||
return custom_date.astimezone(user_time_zone)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CRM Entity Utilities
|
# CRM Entity Utilities
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""RLS-enforced proof for the portal token → org resolution (Option 1).
|
"""RLS-enforced proof for the portal token → org resolution (Option 1).
|
||||||
|
|
||||||
These run only under a non-superuser Postgres role (``RLS_ENFORCE=1
|
These run only under a non-superuser Postgres role (``--ds=crm.test_settings_postgres``), because the whole point is the behaviour
|
||||||
--ds=crm.test_settings_postgres``), because the whole point is the behaviour
|
|
||||||
that a superuser dev DB hides: with an empty ``app.current_org`` the isolation
|
that a superuser dev DB hides: with an empty ``app.current_org`` the isolation
|
||||||
policy makes the estimate row invisible, so the anonymous portal view can only
|
policy makes the estimate row invisible, so the anonymous portal view can only
|
||||||
work if it resolves the org from the unscoped lookup first.
|
work if it resolves the org from the unscoped lookup first.
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ dependencies = [
|
||||||
"django-crum>=0.7.9",
|
"django-crum>=0.7.9",
|
||||||
"disposable-email-domains>=0.0.280",
|
"disposable-email-domains>=0.0.280",
|
||||||
"redis>=8.1.0",
|
"redis>=8.1.0",
|
||||||
"pytz>=2026.5",
|
|
||||||
"requests>=2.34.2",
|
"requests>=2.34.2",
|
||||||
"faker>=40.43.0",
|
"faker>=40.43.0",
|
||||||
"python-dateutil>=2.9.0.post0",
|
"python-dateutil>=2.9.0.post0",
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,4 @@ addopts =
|
||||||
--cov-report=html:htmlcov
|
--cov-report=html:htmlcov
|
||||||
--cov-report=xml:coverage.xml
|
--cov-report=xml:coverage.xml
|
||||||
markers =
|
markers =
|
||||||
slow: marks tests as slow
|
|
||||||
postgres_only: marks tests that require PostgreSQL
|
postgres_only: marks tests that require PostgreSQL
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,6 @@ dependencies = [
|
||||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "pytz" },
|
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sentry-sdk" },
|
{ name = "sentry-sdk" },
|
||||||
|
|
@ -772,7 +771,6 @@ requires-dist = [
|
||||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.6" },
|
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.6" },
|
||||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.4" },
|
{ name = "python-dotenv", specifier = ">=1.2.4" },
|
||||||
{ name = "pytz", specifier = ">=2026.5" },
|
|
||||||
{ name = "redis", specifier = ">=8.1.0" },
|
{ name = "redis", specifier = ">=8.1.0" },
|
||||||
{ name = "requests", specifier = ">=2.34.2" },
|
{ name = "requests", specifier = ">=2.34.2" },
|
||||||
{ name = "sentry-sdk", specifier = ">=2.72.0" },
|
{ name = "sentry-sdk", specifier = ">=2.72.0" },
|
||||||
|
|
@ -1782,15 +1780,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytz"
|
|
||||||
version = "2026.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ Neither layer is optional, and neither is a superset of the other in practice: s
|
||||||
## BaseOrgModel
|
## BaseOrgModel
|
||||||
|
|
||||||
`BaseOrgModel` (`backend/common/base.py`) is a `BaseModel` subclass that adds an `org` foreign key
|
`BaseOrgModel` (`backend/common/base.py`) is a `BaseModel` subclass that adds an `org` foreign key
|
||||||
to `common.Org`, an `OrgScopedManager` (giving you `Model.objects.for_org(org)` and
|
to `common.Org` and a `["org", "-created_at"]` index. Its own
|
||||||
`Model.objects.for_request(request)` helpers), and a `["org", "-created_at"]` index. Its own
|
|
||||||
docstring states the intent plainly: "All tenant-owned models should inherit from this instead of
|
docstring states the intent plainly: "All tenant-owned models should inherit from this instead of
|
||||||
`BaseModel`."
|
`BaseModel`."
|
||||||
|
|
||||||
|
|
@ -58,9 +57,7 @@ class CaseWatcher(BaseModel):
|
||||||
checkout; treat the reasoning captured in the code comments themselves as the source of truth.)
|
checkout; treat the reasoning captured in the code comments themselves as the source of truth.)
|
||||||
|
|
||||||
The practical takeaway: inheriting `BaseOrgModel` is a correct and recommended way to add an `org`
|
The practical takeaway: inheriting `BaseOrgModel` is a correct and recommended way to add an `org`
|
||||||
field to a new model. Doing so also gets you the `OrgScopedManager` helpers
|
field to a new model, and you should default to it unless you have a specific reason to match this
|
||||||
(`Model.objects.for_org(org)`, `Model.objects.for_request(request)`) for free, which is itself a
|
|
||||||
reason to prefer it, and you should default to it unless you have a specific reason to match this
|
|
||||||
codebase's more common hand-rolled-FK convention instead (for consistency with a sibling model in
|
codebase's more common hand-rolled-FK convention instead (for consistency with a sibling model in
|
||||||
the same app, for instance). But inheriting it is not what makes RLS protection work,
|
the same app, for instance). But inheriting it is not what makes RLS protection work,
|
||||||
and it is not sufficient on its own; `PersonalAccessToken` inherits `BaseOrgModel` and is
|
and it is not sufficient on its own; `PersonalAccessToken` inherits `BaseOrgModel` and is
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,6 @@ never runs before both steps have completed; `RequireOrgContext` sits after `Get
|
||||||
the list above and returns a response itself (the 403) rather than calling `get_response` when the
|
the list above and returns a response itself (the 403) rather than calling `get_response` when the
|
||||||
context is missing, so a request that fails tenancy resolution never reaches URL dispatch.
|
context is missing, so a request that fails tenancy resolution never reaches URL dispatch.
|
||||||
|
|
||||||
This file also defines an older class, `SetOrgContext`, whose own docstring describes it as the
|
|
||||||
one wired into `MIDDLEWARE`. That docstring is stale. `SetOrgContext` sets the session variable
|
|
||||||
but never rejects a context-less request; `RequireOrgContext` is what `settings.py` actually
|
|
||||||
lists, and it is the one enforced.
|
|
||||||
|
|
||||||
One layer above middleware, DRF's own authentication runs at view dispatch time
|
One layer above middleware, DRF's own authentication runs at view dispatch time
|
||||||
(`DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK`: `PATAuthentication`, then simplejwt's
|
(`DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK`: `PATAuthentication`, then simplejwt's
|
||||||
`JWTAuthentication`, then `APIKeyAuthentication`) to populate `request.user` for `IsAuthenticated`
|
`JWTAuthentication`, then `APIKeyAuthentication`) to populate `request.user` for `IsAuthenticated`
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ these steps exists; this page is the checklist for *doing* it.
|
||||||
## The checklist
|
## The checklist
|
||||||
|
|
||||||
1. **Inherit `BaseOrgModel`** (`common.base`) so the model gets an `org` foreign key, the UUID
|
1. **Inherit `BaseOrgModel`** (`common.base`) so the model gets an `org` foreign key, the UUID
|
||||||
primary key, audit fields, and the `OrgScopedManager` helpers for free.
|
primary key and audit fields.
|
||||||
2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`.
|
2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`.
|
||||||
3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython`
|
3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython`
|
||||||
operation, with a `dependencies` entry that guarantees the table already exists when it runs.
|
operation, with a `dependencies` entry that guarantees the table already exists when it runs.
|
||||||
|
|
|
||||||
|
|
@ -37,22 +37,20 @@ uv run pytest --no-cov -x
|
||||||
rather than being silently ignored. Every custom marker you use has to be declared in `pytest.ini`'s
|
rather than being silently ignored. Every custom marker you use has to be declared in `pytest.ini`'s
|
||||||
`markers` list. It doesn't touch markers a plugin registers itself, like `@pytest.mark.django_db`
|
`markers` list. It doesn't touch markers a plugin registers itself, like `@pytest.mark.django_db`
|
||||||
(from `pytest-django`). Those work without appearing in `pytest.ini` at all, which is why every
|
(from `pytest-django`). Those work without appearing in `pytest.ini` at all, which is why every
|
||||||
example on this page uses `django_db` freely alongside the two markers that actually are declared.
|
example on this page uses `django_db` freely alongside the one marker that is declared.
|
||||||
|
|
||||||
## Markers
|
## Markers
|
||||||
|
|
||||||
`pytest.ini` declares two custom markers:
|
`pytest.ini` declares one custom marker:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
markers =
|
markers =
|
||||||
slow: marks tests as slow
|
|
||||||
postgres_only: marks tests that require PostgreSQL
|
postgres_only: marks tests that require PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
`postgres_only` marks the tests that need a real PostgreSQL: RLS policy state, isolation behaviour
|
`postgres_only` marks the tests that need a real PostgreSQL: RLS policy state, isolation behaviour
|
||||||
under a role that RLS binds, and session-scoped connection state. List them with
|
under a role that RLS binds, and session-scoped connection state. List them with
|
||||||
`uv run pytest -m postgres_only --collect-only -q`. `slow` is declared and currently applied to
|
`uv run pytest -m postgres_only --collect-only -q`.
|
||||||
nothing.
|
|
||||||
|
|
||||||
## The SQLite caveat
|
## The SQLite caveat
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,11 +155,8 @@ by `RequireOrgContext` (`backend/common/middleware/rls_context.py`, wired into `
|
||||||
`backend/crm/settings.py`) from `request.org`, which is itself derived earlier in the same
|
`backend/crm/settings.py`) from `request.org`, which is itself derived earlier in the same
|
||||||
middleware chain by `GetProfileAndOrg` (`backend/common/middleware/get_company.py`) from the
|
middleware chain by `GetProfileAndOrg` (`backend/common/middleware/get_company.py`) from the
|
||||||
`org_id` claim inside the caller's signed JWT, validated against an active `Profile` for that
|
`org_id` claim inside the caller's signed JWT, validated against an active `Profile` for that
|
||||||
user in that org before it's trusted. (The file also defines a second, older middleware class,
|
user in that org before it's trusted. A request that arrives with no org context is refused with a
|
||||||
`SetOrgContext`, referenced in that file's own module docstring, but `settings.py` wires
|
403 rather than run without one. Every policy uses `NULLIF(current_setting('app.current_org', true), '')`,
|
||||||
`RequireOrgContext` instead, which additionally rejects unauthenticated/context-less requests
|
|
||||||
with a 403 rather than silently proceeding without a context; `RequireOrgContext` is the class
|
|
||||||
actually enforced.) Every policy uses `NULLIF(current_setting('app.current_org', true), '')`,
|
|
||||||
when no context has been set, `current_setting` returns an empty string, `NULLIF` turns that into
|
when no context has been set, `current_setting` returns an empty string, `NULLIF` turns that into
|
||||||
SQL `NULL`, and `org_id::text = NULL` is never true. **No context means zero rows, by design**.
|
SQL `NULL`, and `org_id::text = NULL` is never true. **No context means zero rows, by design**.
|
||||||
A fail-safe default, not an edge case to special-case around.
|
A fail-safe default, not an edge case to special-case around.
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.1.1",
|
"@eslint/compat": "^2.1.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@internationalized/date": "^3.12.4",
|
|
||||||
"@lucide/svelte": "^1.57.0",
|
"@lucide/svelte": "^1.57.0",
|
||||||
"@sveltejs/adapter-node": "^6.0.0",
|
"@sveltejs/adapter-node": "^6.0.0",
|
||||||
"@sveltejs/kit": "^3.0.1",
|
"@sveltejs/kit": "^3.0.1",
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,6 @@ importers:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))
|
version: 10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
'@internationalized/date':
|
|
||||||
specifier: ^3.12.4
|
|
||||||
version: 3.12.4
|
|
||||||
'@lucide/svelte':
|
'@lucide/svelte':
|
||||||
specifier: ^1.57.0
|
specifier: ^1.57.0
|
||||||
version: 1.57.0(svelte@5.57.2)
|
version: 1.57.0(svelte@5.57.2)
|
||||||
|
|
|
||||||
|
|
@ -160,111 +160,3 @@ export async function apiRequest(endpoint, options = {}, locals) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform Django field names to camelCase for SvelteKit
|
|
||||||
*
|
|
||||||
* @param {any} obj - Django object with snake_case fields
|
|
||||||
* @returns {any} Object with camelCase fields
|
|
||||||
*/
|
|
||||||
export function transformFromDjango(obj) {
|
|
||||||
if (!obj) return obj;
|
|
||||||
if (Array.isArray(obj)) return obj.map(transformFromDjango);
|
|
||||||
if (typeof obj !== 'object') return obj;
|
|
||||||
|
|
||||||
/** @type {Record<string, unknown>} */
|
|
||||||
const transformed = {};
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
||||||
transformed[camelKey] = typeof value === 'object' ? transformFromDjango(value) : value;
|
|
||||||
}
|
|
||||||
return transformed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform camelCase field names to Django snake_case
|
|
||||||
*
|
|
||||||
* @param {any} obj - SvelteKit object with camelCase fields
|
|
||||||
* @returns {any} Object with snake_case fields
|
|
||||||
*/
|
|
||||||
export function transformToDjango(obj) {
|
|
||||||
if (!obj) return obj;
|
|
||||||
if (Array.isArray(obj)) return obj.map(transformToDjango);
|
|
||||||
if (typeof obj !== 'object') return obj;
|
|
||||||
|
|
||||||
/** @type {Record<string, unknown>} */
|
|
||||||
const transformed = {};
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
const snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
||||||
transformed[snakeKey] = typeof value === 'object' ? transformToDjango(value) : value;
|
|
||||||
}
|
|
||||||
return transformed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Django query parameters from filter object
|
|
||||||
*
|
|
||||||
* @param {Object} filters - Filter object
|
|
||||||
* @param {number} [filters.page] - Page number (1-indexed)
|
|
||||||
* @param {number} [filters.limit] - Items per page
|
|
||||||
* @param {string} [filters.sort] - Sort field name
|
|
||||||
* @param {string} [filters.order] - Sort order ('asc' or 'desc')
|
|
||||||
* @returns {URLSearchParams} Query parameters for Django API
|
|
||||||
*/
|
|
||||||
export function buildQueryParams(filters = {}) {
|
|
||||||
const { page = 1, limit = 10, sort, order = 'asc', ...otherFilters } = filters;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
params.append('limit', limit.toString());
|
|
||||||
params.append('offset', ((page - 1) * limit).toString());
|
|
||||||
|
|
||||||
// Sorting (Django uses - prefix for descending)
|
|
||||||
if (sort) {
|
|
||||||
const ordering = order === 'desc' ? `-${sort}` : sort;
|
|
||||||
params.append('ordering', ordering);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other filters
|
|
||||||
for (const [key, value] of Object.entries(otherFilters)) {
|
|
||||||
if (value !== null && value !== undefined && value !== '') {
|
|
||||||
params.append(key, value.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract pagination info from Django response
|
|
||||||
*
|
|
||||||
* @param {{ count?: number, next?: string | null, previous?: string | null, results?: unknown[] }} response - Django paginated response
|
|
||||||
* @param {number} [limit=10] - Items per page
|
|
||||||
* @returns {{ page: number, limit: number, total: number, totalPages: number, hasNext: boolean, hasPrevious: boolean }} Pagination info compatible with SvelteKit
|
|
||||||
*/
|
|
||||||
export function extractPagination(response, limit = 10) {
|
|
||||||
const total = response.count || 0;
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
// Determine current page from offset in next/previous URLs
|
|
||||||
let currentPage = 1;
|
|
||||||
if (response.next) {
|
|
||||||
const url = new URL(response.next, 'http://localhost');
|
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
||||||
currentPage = Math.floor(offset / limit);
|
|
||||||
} else if (response.previous) {
|
|
||||||
const url = new URL(response.previous, 'http://localhost');
|
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
||||||
currentPage = Math.floor(offset / limit) + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
page: currentPage,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages,
|
|
||||||
hasNext: response.next !== null,
|
|
||||||
hasPrevious: response.previous !== null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
<script>
|
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
|
||||||
MessageSquare,
|
|
||||||
UserPlus,
|
|
||||||
ChevronRight,
|
|
||||||
Clock
|
|
||||||
} from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} ActivityItem
|
|
||||||
* @property {string} id
|
|
||||||
* @property {string} [description]
|
|
||||||
* @property {string} [message]
|
|
||||||
* @property {string} [timestamp]
|
|
||||||
* @property {string} [createdAt]
|
|
||||||
* @property {string} [action]
|
|
||||||
* @property {string} [entityType]
|
|
||||||
* @property {string} [entityName]
|
|
||||||
* @property {{ name?: string }} [user]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {ActivityItem[]} [activities=[]] - List of activity items
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let { activities = [] } = $props();
|
|
||||||
|
|
||||||
let showAll = $state(false);
|
|
||||||
|
|
||||||
// Using design system activity tokens
|
|
||||||
const actionConfig =
|
|
||||||
/** @type {Record<string, { icon: typeof Activity, bg: string, text: string }>} */ ({
|
|
||||||
CREATE: {
|
|
||||||
icon: Plus,
|
|
||||||
bg: 'bg-[var(--color-success-light)] dark:bg-[var(--color-success-default)]/15',
|
|
||||||
text: 'text-[var(--color-success-default)]'
|
|
||||||
},
|
|
||||||
UPDATE: {
|
|
||||||
icon: Pencil,
|
|
||||||
bg: 'bg-[var(--color-primary-light)] dark:bg-[var(--color-primary-default)]/15',
|
|
||||||
text: 'text-[var(--color-primary-default)]'
|
|
||||||
},
|
|
||||||
DELETE: {
|
|
||||||
icon: Trash2,
|
|
||||||
bg: 'bg-[var(--color-negative-light)] dark:bg-[var(--color-negative-default)]/15',
|
|
||||||
text: 'text-[var(--color-negative-default)]'
|
|
||||||
},
|
|
||||||
VIEW: {
|
|
||||||
icon: Eye,
|
|
||||||
bg: 'bg-[var(--surface-sunken)]',
|
|
||||||
text: 'text-[var(--text-secondary)]'
|
|
||||||
},
|
|
||||||
COMMENT: {
|
|
||||||
icon: MessageSquare,
|
|
||||||
bg: 'bg-[var(--activity-note)]/10 dark:bg-[var(--activity-note)]/15',
|
|
||||||
text: 'text-[var(--activity-note)]'
|
|
||||||
},
|
|
||||||
ASSIGN: {
|
|
||||||
icon: UserPlus,
|
|
||||||
bg: 'bg-[var(--activity-meeting)]/10 dark:bg-[var(--activity-meeting)]/15',
|
|
||||||
text: 'text-[var(--activity-meeting)]'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultConfig = {
|
|
||||||
icon: Activity,
|
|
||||||
bg: 'bg-[var(--surface-sunken)]',
|
|
||||||
text: 'text-[var(--text-tertiary)]'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get date category for grouping
|
|
||||||
* @param {string | undefined} dateStr
|
|
||||||
*/
|
|
||||||
function getDateCategory(dateStr) {
|
|
||||||
if (!dateStr) return 'Earlier';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
const yesterday = new SvelteDate(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const weekAgo = new SvelteDate(today);
|
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
||||||
|
|
||||||
if (date >= today) return 'Today';
|
|
||||||
if (date >= yesterday) return 'Yesterday';
|
|
||||||
if (date >= weekAgo) return 'This Week';
|
|
||||||
return 'Earlier';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format time only
|
|
||||||
* @param {string | undefined} dateStr
|
|
||||||
*/
|
|
||||||
function formatTime(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group activities by date category
|
|
||||||
* @param {ActivityItem[]} activities
|
|
||||||
*/
|
|
||||||
function groupByDate(activities) {
|
|
||||||
const groups = /** @type {Record<string, ActivityItem[]>} */ ({});
|
|
||||||
const order = ['Today', 'Yesterday', 'This Week', 'Earlier'];
|
|
||||||
|
|
||||||
for (const activity of activities) {
|
|
||||||
const category = getDateCategory(activity.timestamp || activity.createdAt);
|
|
||||||
if (!groups[category]) groups[category] = [];
|
|
||||||
groups[category].push(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return order
|
|
||||||
.filter((cat) => groups[cat]?.length > 0)
|
|
||||||
.map((cat) => ({
|
|
||||||
category: cat,
|
|
||||||
items: groups[cat]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayedActivities = $derived(showAll ? activities : activities.slice(0, 5));
|
|
||||||
const groupedActivities = $derived(groupByDate(displayedActivities));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--surface-raised)] dark:bg-[var(--surface-raised)]/80 dark:backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<!-- Header - Orange accent -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between border-b border-[var(--border-default)]/50 px-5 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex size-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-primary-light)] dark:bg-[var(--color-primary-default)]/15"
|
|
||||||
>
|
|
||||||
<Activity class="size-4 text-[var(--color-primary-default)]" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-sm font-semibold tracking-tight text-[var(--text-primary)]">
|
|
||||||
Recent Activity
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" href="/activities" class="gap-1 text-xs font-medium">
|
|
||||||
View all
|
|
||||||
<ChevronRight class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activity list -->
|
|
||||||
<div class="p-5">
|
|
||||||
{#if activities.length === 0}
|
|
||||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
|
||||||
<div
|
|
||||||
class="mb-3 flex size-12 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--surface-sunken)]"
|
|
||||||
>
|
|
||||||
<Clock class="size-6 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm font-medium text-[var(--text-secondary)]">No recent activity</p>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">Actions will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-5">
|
|
||||||
{#each groupedActivities as group (group.category)}
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="mb-3 text-[10px] font-semibold tracking-widest text-[var(--text-tertiary)] uppercase"
|
|
||||||
>
|
|
||||||
{group.category}
|
|
||||||
</p>
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each group.items as activity (activity.id)}
|
|
||||||
{@const config = actionConfig[activity.action || ''] || defaultConfig}
|
|
||||||
{@const Icon = config.icon}
|
|
||||||
<div
|
|
||||||
class="group -mx-2 flex items-start gap-3 rounded-[var(--radius-md)] px-2 py-2 transition-all duration-200 hover:bg-[var(--color-primary-light)] dark:hover:bg-[var(--color-primary-default)]/5"
|
|
||||||
>
|
|
||||||
<!-- Icon -->
|
|
||||||
<div
|
|
||||||
class="flex size-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] transition-transform duration-200 group-hover:scale-105 {config.bg}"
|
|
||||||
>
|
|
||||||
<Icon class="size-3.5 {config.text}" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm leading-snug text-[var(--text-primary)]">
|
|
||||||
{activity.description ||
|
|
||||||
activity.message ||
|
|
||||||
`${activity.action} ${activity.entityType}: ${activity.entityName}`}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="mt-1 flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{activity.user?.name || 'System'}</span>
|
|
||||||
<span class="text-[var(--text-tertiary)]">·</span>
|
|
||||||
<span>{formatTime(activity.timestamp || activity.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activities.length > 5 && !showAll}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="mt-4 w-full text-xs font-medium"
|
|
||||||
onclick={() => (showAll = true)}
|
|
||||||
>
|
|
||||||
Show more ({activities.length - 5} more)
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<script>
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { asInternalPath } from '#lib/utils/paths.js';
|
|
||||||
import { AlertCircle, Calendar, Phone, Flame, Zap } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {number} [overdueCount] - Number of overdue tasks
|
|
||||||
* @property {number} [todayCount] - Number of tasks due today
|
|
||||||
* @property {number} [followupsCount] - Number of follow-ups scheduled today
|
|
||||||
* @property {number} [hotLeadsCount] - Number of hot leads
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let { overdueCount = 0, todayCount = 0, followupsCount = 0, hotLeadsCount = 0 } = $props();
|
|
||||||
|
|
||||||
const items = $derived([
|
|
||||||
{
|
|
||||||
href: '/tasks?filter=overdue',
|
|
||||||
icon: AlertCircle,
|
|
||||||
iconClass: 'text-[var(--task-overdue)]',
|
|
||||||
bgClass: 'bg-[var(--task-overdue-bg)] dark:bg-[var(--task-overdue)]/15',
|
|
||||||
count: overdueCount,
|
|
||||||
label: 'Overdue',
|
|
||||||
show: overdueCount > 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/tasks?filter=today',
|
|
||||||
icon: Calendar,
|
|
||||||
iconClass: 'text-[var(--task-due-today)]',
|
|
||||||
bgClass: 'bg-[var(--task-due-today-bg)] dark:bg-[var(--task-due-today)]/15',
|
|
||||||
count: todayCount,
|
|
||||||
label: 'Due Today',
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/leads?filter=followup_today',
|
|
||||||
icon: Phone,
|
|
||||||
iconClass: 'text-[var(--activity-call)]',
|
|
||||||
bgClass: 'bg-[var(--color-success-light)] dark:bg-[var(--activity-call)]/15',
|
|
||||||
count: followupsCount,
|
|
||||||
label: 'Follow-ups',
|
|
||||||
show: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/leads?rating=HOT',
|
|
||||||
icon: Flame,
|
|
||||||
iconClass: 'text-[var(--lead-hot)]',
|
|
||||||
bgClass: 'bg-[var(--lead-hot-bg)] dark:bg-[var(--lead-hot)]/15',
|
|
||||||
count: hotLeadsCount,
|
|
||||||
label: 'Hot Leads',
|
|
||||||
show: hotLeadsCount > 0
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const visibleItems = $derived(items.filter((item) => item.show));
|
|
||||||
const hasUrgentItems = $derived(overdueCount > 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if visibleItems.length > 0}
|
|
||||||
<div
|
|
||||||
class="relative overflow-hidden rounded-[var(--radius-lg)] border px-4 py-3 backdrop-blur-sm transition-all duration-300 sm:px-6
|
|
||||||
{hasUrgentItems
|
|
||||||
? 'border-[var(--task-overdue)]/30 bg-[var(--task-overdue-bg)] dark:border-[var(--task-overdue)]/20 dark:bg-[var(--task-overdue)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--surface-raised)]/50 dark:bg-[var(--surface-raised)]/30'}"
|
|
||||||
>
|
|
||||||
<!-- Subtle gradient overlay -->
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent dark:via-white/[0.02]"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="relative flex items-center gap-3 sm:gap-5">
|
|
||||||
<!-- Label with icon - Orange accent -->
|
|
||||||
<div class="hidden items-center gap-2 sm:flex">
|
|
||||||
<div
|
|
||||||
class="flex size-7 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-primary-light)] dark:bg-[var(--color-primary-default)]/15"
|
|
||||||
>
|
|
||||||
<Zap class="size-4 text-[var(--color-primary-default)]" />
|
|
||||||
</div>
|
|
||||||
<span class="text-xs font-semibold tracking-wider text-[var(--text-secondary)] uppercase">
|
|
||||||
Today's Focus
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="hidden h-6 w-px bg-[var(--border-default)]/50 sm:block"></div>
|
|
||||||
|
|
||||||
<!-- Items -->
|
|
||||||
<div class="flex flex-1 items-center gap-1 overflow-x-auto sm:gap-2">
|
|
||||||
{#each visibleItems as item (item.href)}
|
|
||||||
<a
|
|
||||||
href={resolve(asInternalPath(item.href))}
|
|
||||||
class="group flex items-center gap-2 rounded-[var(--radius-md)] px-3 py-2 transition-all duration-200 hover:bg-white/50 sm:gap-2.5 sm:px-4 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex size-8 items-center justify-center rounded-[var(--radius-md)] transition-transform duration-200 group-hover:scale-110 {item.bgClass}"
|
|
||||||
>
|
|
||||||
<item.icon class="size-4 {item.iconClass}" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-base font-bold text-[var(--text-primary)] tabular-nums"
|
|
||||||
>{item.count}</span
|
|
||||||
>
|
|
||||||
<span class="hidden text-xs font-medium text-[var(--text-secondary)] sm:inline"
|
|
||||||
>{item.label}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
<script>
|
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
import { Flame, Phone, Mail, ChevronRight, Calendar, Sparkles } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Lead
|
|
||||||
* @property {string} id
|
|
||||||
* @property {string} [first_name]
|
|
||||||
* @property {string} [last_name]
|
|
||||||
* @property {string} [company]
|
|
||||||
* @property {string} [rating]
|
|
||||||
* @property {string} [next_follow_up]
|
|
||||||
* @property {string} [last_contacted]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {Lead[]} [leads] - Hot leads
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let { leads = [] } = $props();
|
|
||||||
|
|
||||||
// Using design system lead temperature tokens
|
|
||||||
const ratingConfig =
|
|
||||||
/** @type {Record<string, { bg: string, text: string, border: string, iconColor: string }>} */ ({
|
|
||||||
HOT: {
|
|
||||||
bg: 'bg-[var(--lead-hot-bg)] dark:bg-[var(--lead-hot)]/15',
|
|
||||||
text: 'text-[var(--lead-hot)]',
|
|
||||||
border: 'border-[var(--lead-hot)]/30',
|
|
||||||
iconColor: 'text-[var(--lead-hot)]'
|
|
||||||
},
|
|
||||||
WARM: {
|
|
||||||
bg: 'bg-[var(--lead-warm-bg)] dark:bg-[var(--lead-warm)]/15',
|
|
||||||
text: 'text-[var(--lead-warm)]',
|
|
||||||
border: 'border-[var(--lead-warm)]/30',
|
|
||||||
iconColor: 'text-[var(--lead-warm)]'
|
|
||||||
},
|
|
||||||
COLD: {
|
|
||||||
bg: 'bg-[var(--lead-cold-bg)] dark:bg-[var(--lead-cold)]/15',
|
|
||||||
text: 'text-[var(--lead-cold)]',
|
|
||||||
border: 'border-[var(--lead-cold)]/30',
|
|
||||||
iconColor: 'text-[var(--lead-cold)]'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for display
|
|
||||||
* @param {string | null | undefined} dateStr
|
|
||||||
*/
|
|
||||||
function formatFollowUp(dateStr) {
|
|
||||||
if (!dateStr) return 'No follow-up set';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const today = new SvelteDate();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const tomorrow = new SvelteDate(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
const dateOnly = new SvelteDate(date);
|
|
||||||
dateOnly.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (dateOnly.getTime() === today.getTime()) return 'Today';
|
|
||||||
if (dateOnly.getTime() === tomorrow.getTime()) return 'Tomorrow';
|
|
||||||
if (dateOnly.getTime() < today.getTime()) {
|
|
||||||
const days = Math.floor((today.getTime() - dateOnly.getTime()) / (1000 * 60 * 60 * 24));
|
|
||||||
return `${days}d overdue`;
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if follow-up is overdue
|
|
||||||
* @param {string | null | undefined} dateStr
|
|
||||||
*/
|
|
||||||
function isOverdue(dateStr) {
|
|
||||||
if (!dateStr) return false;
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const today = new SvelteDate();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
return date < today;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get lead name
|
|
||||||
* @param {Lead} lead
|
|
||||||
*/
|
|
||||||
function getLeadName(lead) {
|
|
||||||
const parts = [lead.first_name, lead.last_name].filter(Boolean);
|
|
||||||
return parts.length > 0 ? parts.join(' ') : 'Unnamed Lead';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex h-full flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--surface-raised)] dark:bg-[var(--surface-raised)]/80 dark:backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<!-- Header - Orange accent for hot leads -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between border-b border-[var(--border-default)]/50 px-5 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex size-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--lead-hot-bg)] dark:bg-[var(--lead-hot)]/15"
|
|
||||||
>
|
|
||||||
<Flame class="size-4 text-[var(--lead-hot)]" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-sm font-semibold tracking-tight text-[var(--text-primary)]">Hot Leads</h3>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" href="/leads?rating=HOT" class="gap-1 text-xs font-medium">
|
|
||||||
View all
|
|
||||||
<ChevronRight class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Leads list -->
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
{#if leads.length === 0}
|
|
||||||
<div class="flex h-full flex-col items-center justify-center py-10 text-center">
|
|
||||||
<div
|
|
||||||
class="mb-3 flex size-12 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--surface-sunken)]"
|
|
||||||
>
|
|
||||||
<Sparkles class="size-6 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm font-medium text-[var(--text-secondary)]">No hot leads</p>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">Mark leads as "Hot" to see them here</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="divide-y divide-[var(--border-default)]/30">
|
|
||||||
{#each leads as lead (lead.id)}
|
|
||||||
{@const config = ratingConfig[lead.rating || 'HOT'] || ratingConfig.HOT}
|
|
||||||
<a
|
|
||||||
href={resolve(`leads?view=${lead.id}`)}
|
|
||||||
class="group flex items-center gap-3 px-5 py-3 transition-all duration-200 hover:bg-[var(--color-primary-light)] dark:hover:bg-[var(--color-primary-default)]/5"
|
|
||||||
>
|
|
||||||
<!-- Lead info -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
class="truncate text-sm font-medium text-[var(--text-primary)] transition-colors group-hover:text-[var(--color-primary-default)]"
|
|
||||||
>
|
|
||||||
{getLeadName(lead)}
|
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
|
||||||
{lead.company || 'No company'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rating badge -->
|
|
||||||
<Badge
|
|
||||||
class="flex-shrink-0 gap-1.5 border text-[10px] font-semibold {config.bg} {config.text} {config.border}"
|
|
||||||
>
|
|
||||||
<Flame class="size-3 {config.iconColor}" />
|
|
||||||
{lead.rating || 'HOT'}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Follow-up date -->
|
|
||||||
{#if lead.next_follow_up}
|
|
||||||
<div class="flex flex-shrink-0 items-center gap-1.5">
|
|
||||||
<Calendar class="size-3.5 text-[var(--text-tertiary)]" />
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium tabular-nums
|
|
||||||
{isOverdue(lead.next_follow_up)
|
|
||||||
? 'text-[var(--task-overdue)]'
|
|
||||||
: 'text-[var(--text-secondary)]'}"
|
|
||||||
>
|
|
||||||
{formatFollowUp(lead.next_follow_up)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hover actions -->
|
|
||||||
<div
|
|
||||||
class="flex flex-shrink-0 gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="size-7 rounded-[var(--radius-md)] hover:bg-[var(--activity-call)]/10 hover:text-[var(--activity-call)]"
|
|
||||||
title="Call"
|
|
||||||
>
|
|
||||||
<Phone class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="size-7 rounded-[var(--radius-md)] hover:bg-[var(--activity-email)]/10 hover:text-[var(--activity-email)]"
|
|
||||||
title="Email"
|
|
||||||
>
|
|
||||||
<Mail class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { TrendingUp, TrendingDown } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {string} label - The metric label (e.g., "Active Leads")
|
|
||||||
* @property {string | number} value - The metric value
|
|
||||||
* @property {string} [subtitle] - Small note below the value (e.g., "INR only")
|
|
||||||
* @property {import('svelte').Snippet} [icon] - Icon snippet to display
|
|
||||||
* @property {number} [trend] - Percentage change (positive or negative)
|
|
||||||
* @property {string} [trendLabel] - Label for trend (e.g., "vs last month")
|
|
||||||
* @property {'orange' | 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue'} [accentColor] - Accent color theme
|
|
||||||
* @property {string} [class] - Additional classes
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props & Record<string, any>} */
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
subtitle,
|
|
||||||
icon,
|
|
||||||
trend,
|
|
||||||
trendLabel,
|
|
||||||
accentColor = 'orange',
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const hasTrend = $derived(trend !== undefined && trend !== null);
|
|
||||||
const isPositive = $derived(trend !== undefined && trend >= 0);
|
|
||||||
|
|
||||||
// Color configurations using design system tokens
|
|
||||||
const colorConfig = {
|
|
||||||
orange: {
|
|
||||||
iconBg: 'bg-[var(--color-primary-light)] dark:bg-[var(--color-primary-default)]/15',
|
|
||||||
iconColor: 'text-[var(--color-primary-default)] dark:text-[var(--color-primary-default)]',
|
|
||||||
borderGlow: 'hover:border-[var(--color-primary-default)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--color-primary-default)]/10'
|
|
||||||
},
|
|
||||||
cyan: {
|
|
||||||
iconBg: 'bg-cyan-500/10 dark:bg-cyan-500/20',
|
|
||||||
iconColor: 'text-cyan-600 dark:text-cyan-400',
|
|
||||||
borderGlow: 'hover:border-cyan-500/30 dark:hover:border-cyan-400/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-cyan-500/10'
|
|
||||||
},
|
|
||||||
violet: {
|
|
||||||
iconBg: 'bg-[var(--stage-proposal-bg)] dark:bg-[var(--stage-proposal)]/15',
|
|
||||||
iconColor: 'text-[var(--stage-proposal)] dark:text-[var(--stage-proposal)]',
|
|
||||||
borderGlow: 'hover:border-[var(--stage-proposal)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--stage-proposal)]/10'
|
|
||||||
},
|
|
||||||
blue: {
|
|
||||||
iconBg: 'bg-[var(--stage-qualified-bg)] dark:bg-[var(--stage-qualified)]/15',
|
|
||||||
iconColor: 'text-[var(--stage-qualified)] dark:text-[var(--stage-qualified)]',
|
|
||||||
borderGlow: 'hover:border-[var(--stage-qualified)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--stage-qualified)]/10'
|
|
||||||
},
|
|
||||||
emerald: {
|
|
||||||
iconBg: 'bg-[var(--color-success-light)] dark:bg-[var(--stage-won)]/15',
|
|
||||||
iconColor: 'text-[var(--stage-won)] dark:text-[var(--stage-won)]',
|
|
||||||
borderGlow: 'hover:border-[var(--stage-won)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--stage-won)]/10'
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
iconBg: 'bg-[var(--stage-negotiation-bg)] dark:bg-[var(--stage-negotiation)]/15',
|
|
||||||
iconColor: 'text-[var(--stage-negotiation)] dark:text-[var(--stage-negotiation)]',
|
|
||||||
borderGlow: 'hover:border-[var(--stage-negotiation)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--stage-negotiation)]/10'
|
|
||||||
},
|
|
||||||
rose: {
|
|
||||||
iconBg: 'bg-[var(--stage-lost-bg)] dark:bg-[var(--stage-lost)]/15',
|
|
||||||
iconColor: 'text-[var(--stage-lost)] dark:text-[var(--stage-lost)]',
|
|
||||||
borderGlow: 'hover:border-[var(--stage-lost)]/30',
|
|
||||||
shadowGlow: 'dark:hover:shadow-[var(--stage-lost)]/10'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = $derived(colorConfig[accentColor] || colorConfig.orange);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'group relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--surface-raised)] p-5 transition-all duration-300',
|
|
||||||
'hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]',
|
|
||||||
'dark:border-[var(--border-default)] dark:bg-[var(--surface-raised)]/80 dark:backdrop-blur-sm dark:hover:shadow-lg',
|
|
||||||
colors.borderGlow,
|
|
||||||
colors.shadowGlow,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<!-- Subtle gradient overlay on hover -->
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-default)]/5 via-transparent to-transparent dark:from-[var(--color-primary-default)]/[0.03]"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative flex items-start justify-between gap-4">
|
|
||||||
<div class="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<!-- Label -->
|
|
||||||
<p class="text-xs font-semibold tracking-wider text-[var(--text-secondary)] uppercase">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Value -->
|
|
||||||
<p
|
|
||||||
class="truncate text-2xl font-bold tracking-tight text-[var(--text-primary)] tabular-nums lg:text-3xl"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Subtitle -->
|
|
||||||
{#if subtitle}
|
|
||||||
<p class="text-[10px] font-medium text-[var(--text-tertiary)]">{subtitle}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Trend indicator -->
|
|
||||||
{#if hasTrend}
|
|
||||||
<div class="mt-1 flex items-center gap-1.5">
|
|
||||||
{#if isPositive}
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1 rounded-full bg-[var(--color-success-light)] px-2 py-0.5 dark:bg-[var(--stage-won)]/15"
|
|
||||||
>
|
|
||||||
<TrendingUp class="size-3 text-[var(--stage-won)]" />
|
|
||||||
<span class="text-xs font-semibold text-[var(--stage-won)]">
|
|
||||||
+{trend}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1 rounded-full bg-[var(--stage-lost-bg)] px-2 py-0.5 dark:bg-[var(--stage-lost)]/15"
|
|
||||||
>
|
|
||||||
<TrendingDown class="size-3 text-[var(--stage-lost)]" />
|
|
||||||
<span class="text-xs font-semibold text-[var(--stage-lost)]">
|
|
||||||
{trend}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if trendLabel}
|
|
||||||
<span class="text-[10px] text-[var(--text-tertiary)]">{trendLabel}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Icon -->
|
|
||||||
{#if icon}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex size-11 flex-shrink-0 items-center justify-center rounded-[var(--radius-lg)] transition-transform duration-300 group-hover:scale-105',
|
|
||||||
colors.iconBg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div class={colors.iconColor}>
|
|
||||||
{@render icon()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
<script>
|
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
import { Circle, CheckCircle2, ChevronRight, ListTodo } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Task
|
|
||||||
* @property {string} id
|
|
||||||
* @property {string} subject
|
|
||||||
* @property {string} status
|
|
||||||
* @property {string} priority
|
|
||||||
* @property {string} [dueDate]
|
|
||||||
* @property {boolean} [isOverdue]
|
|
||||||
* @property {boolean} [isDueToday]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {Task[]} [tasks] - All tasks
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let { tasks = [] } = $props();
|
|
||||||
|
|
||||||
/** @type {'all' | 'overdue' | 'today' | 'week'} */
|
|
||||||
let filter = $state('all');
|
|
||||||
|
|
||||||
const today = new SvelteDate();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const weekEnd = new SvelteDate(today);
|
|
||||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a date is today
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function isToday(dateStr) {
|
|
||||||
if (!dateStr) return false;
|
|
||||||
const date = new SvelteDate(dateStr);
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
return date.getTime() === today.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a date is overdue
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function isOverdue(dateStr) {
|
|
||||||
if (!dateStr) return false;
|
|
||||||
const date = new SvelteDate(dateStr);
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
return date.getTime() < today.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a date is within this week
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function isThisWeek(dateStr) {
|
|
||||||
if (!dateStr) return false;
|
|
||||||
const date = new SvelteDate(dateStr);
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
return date.getTime() >= today.getTime() && date.getTime() <= weekEnd.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredTasks = $derived(() => {
|
|
||||||
return tasks.filter((task) => {
|
|
||||||
if (task.status === 'Completed') return false;
|
|
||||||
if (filter === 'overdue') return isOverdue(task.dueDate);
|
|
||||||
if (filter === 'today') return isToday(task.dueDate);
|
|
||||||
if (filter === 'week') return isThisWeek(task.dueDate);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const overdueTasks = $derived(
|
|
||||||
tasks.filter((t) => t.status !== 'Completed' && isOverdue(t.dueDate))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Using design system priority tokens
|
|
||||||
const priorityConfig =
|
|
||||||
/** @type {Record<string, { bg: string, text: string, border: string }>} */ ({
|
|
||||||
Urgent: {
|
|
||||||
bg: 'bg-[var(--priority-urgent-bg)] dark:bg-[var(--priority-urgent)]/15',
|
|
||||||
text: 'text-[var(--priority-urgent)]',
|
|
||||||
border: 'border-[var(--priority-urgent)]/30'
|
|
||||||
},
|
|
||||||
High: {
|
|
||||||
bg: 'bg-[var(--priority-high-bg)] dark:bg-[var(--priority-high)]/15',
|
|
||||||
text: 'text-[var(--priority-high)]',
|
|
||||||
border: 'border-[var(--priority-high)]/30'
|
|
||||||
},
|
|
||||||
Medium: {
|
|
||||||
bg: 'bg-[var(--priority-medium-bg)] dark:bg-[var(--priority-medium)]/15',
|
|
||||||
text: 'text-[var(--priority-medium)]',
|
|
||||||
border: 'border-[var(--priority-medium)]/30'
|
|
||||||
},
|
|
||||||
Low: {
|
|
||||||
bg: 'bg-[var(--priority-low-bg)] dark:bg-[var(--priority-low)]/15',
|
|
||||||
text: 'text-[var(--priority-low)]',
|
|
||||||
border: 'border-[var(--priority-low)]/30'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get priority config
|
|
||||||
* @param {string} priority
|
|
||||||
*/
|
|
||||||
function getPriorityConfig(priority) {
|
|
||||||
return priorityConfig[priority] || priorityConfig['Medium'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for display
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isToday(dateStr)) return 'Today';
|
|
||||||
if (isOverdue(dateStr)) {
|
|
||||||
const days = Math.floor((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
||||||
return days === 1 ? 'Yesterday' : `${days}d ago`;
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {{ id: 'all' | 'overdue' | 'today' | 'week', label: string }[]} */
|
|
||||||
const filterButtons = [
|
|
||||||
{ id: 'all', label: 'All' },
|
|
||||||
{ id: 'overdue', label: 'Overdue' },
|
|
||||||
{ id: 'today', label: 'Today' },
|
|
||||||
{ id: 'week', label: 'Week' }
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex h-full flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--surface-raised)] dark:bg-[var(--surface-raised)]/80 dark:backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<!-- Header - Orange accent -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between border-b border-[var(--border-default)]/50 px-5 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex size-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-primary-light)] dark:bg-[var(--color-primary-default)]/15"
|
|
||||||
>
|
|
||||||
<ListTodo class="size-4 text-[var(--color-primary-default)]" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-sm font-semibold tracking-tight text-[var(--text-primary)]">My Tasks</h3>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" href="/tasks" class="gap-1 text-xs font-medium">
|
|
||||||
View all
|
|
||||||
<ChevronRight class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter tabs -->
|
|
||||||
<div class="flex gap-1 border-b border-[var(--border-default)]/50 px-4 py-2">
|
|
||||||
{#each filterButtons as btn (btn.id)}
|
|
||||||
<Button
|
|
||||||
variant={filter === btn.id ? 'secondary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
class="h-7 px-3 text-xs font-medium transition-all duration-200
|
|
||||||
{filter === btn.id
|
|
||||||
? 'bg-[var(--surface-sunken)] shadow-sm'
|
|
||||||
: 'hover:bg-[var(--surface-sunken)]/50'}"
|
|
||||||
onclick={() => (filter = btn.id)}
|
|
||||||
>
|
|
||||||
{btn.label}
|
|
||||||
{#if btn.id === 'overdue' && overdueTasks.length > 0}
|
|
||||||
<Badge
|
|
||||||
class="ml-1.5 h-4 min-w-4 bg-[var(--task-overdue)] px-1 text-[10px] font-bold text-white"
|
|
||||||
>
|
|
||||||
{overdueTasks.length}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task list -->
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
{#if filteredTasks().length === 0}
|
|
||||||
<div class="flex h-full flex-col items-center justify-center py-10 text-center">
|
|
||||||
<div
|
|
||||||
class="mb-3 flex size-12 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--surface-sunken)]"
|
|
||||||
>
|
|
||||||
<ListTodo class="size-6 text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm font-medium text-[var(--text-secondary)]">No tasks found</p>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">All caught up!</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="divide-y divide-[var(--border-default)]/30">
|
|
||||||
{#each filteredTasks() as task (task.id)}
|
|
||||||
{@const config = getPriorityConfig(task.priority)}
|
|
||||||
<a
|
|
||||||
href={resolve(`tasks?view=${task.id}`)}
|
|
||||||
class="group flex items-center gap-3 px-5 py-3 transition-all duration-200 hover:bg-[var(--color-primary-light)] dark:hover:bg-[var(--color-primary-default)]/5"
|
|
||||||
>
|
|
||||||
<!-- Status icon -->
|
|
||||||
<button
|
|
||||||
class="flex-shrink-0 text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
{#if task.status === 'Completed'}
|
|
||||||
<CheckCircle2 class="size-5 text-[var(--task-completed)]" />
|
|
||||||
{:else}
|
|
||||||
<Circle class="size-5" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Task details -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
class="truncate text-sm font-medium text-[var(--text-primary)] transition-colors group-hover:text-[var(--color-primary-default)]
|
|
||||||
{task.status === 'Completed' ? 'text-[var(--text-tertiary)] line-through' : ''}"
|
|
||||||
>
|
|
||||||
{task.subject}
|
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 truncate text-xs text-[var(--text-secondary)]">{task.status}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority badge -->
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="flex-shrink-0 border text-[10px] font-semibold {config.bg} {config.text} {config.border}"
|
|
||||||
>
|
|
||||||
{task.priority}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Due date -->
|
|
||||||
{#if task.dueDate}
|
|
||||||
<span
|
|
||||||
class="flex-shrink-0 text-xs font-medium tabular-nums
|
|
||||||
{isOverdue(task.dueDate)
|
|
||||||
? 'text-[var(--task-overdue)]'
|
|
||||||
: 'text-[var(--text-secondary)]'}"
|
|
||||||
>
|
|
||||||
{formatDate(task.dueDate)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export { default as KPICard } from './KPICard.svelte';
|
|
||||||
export { default as ActivityFeed } from './ActivityFeed.svelte';
|
|
||||||
export { default as FocusBar } from './FocusBar.svelte';
|
|
||||||
export { default as TaskList } from './TaskList.svelte';
|
|
||||||
export { default as HotLeadsPanel } from './HotLeadsPanel.svelte';
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script>
|
|
||||||
let { class: className = '', ...rest } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={className}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
|
||||||
/>
|
|
||||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
|
||||||
</svg>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script>
|
|
||||||
let { class: className = '', ...rest } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class={className}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-7a6 6 0 0 1 6-6z" />
|
|
||||||
<rect width="4" height="12" x="2" y="9" />
|
|
||||||
<circle cx="4" cy="4" r="2" />
|
|
||||||
</svg>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { default as LinkedinIcon } from './LinkedinIcon.svelte';
|
|
||||||
export { default as GithubIcon } from './GithubIcon.svelte';
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<script>
|
|
||||||
import { X } from '@lucide/svelte';
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* label: string,
|
|
||||||
* active?: boolean,
|
|
||||||
* value?: string,
|
|
||||||
* icon?: import('svelte').Snippet,
|
|
||||||
* onclick?: (e: MouseEvent) => void,
|
|
||||||
* onclear?: (e: MouseEvent) => void,
|
|
||||||
* dashed?: boolean,
|
|
||||||
* class?: string,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
active = false,
|
|
||||||
value = '',
|
|
||||||
icon,
|
|
||||||
onclick,
|
|
||||||
onclear,
|
|
||||||
dashed = false,
|
|
||||||
class: className = ''
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const base =
|
|
||||||
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] px-[9px] text-[13px] font-medium leading-none transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--ring)]';
|
|
||||||
const neutral =
|
|
||||||
'border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]';
|
|
||||||
const activeCls =
|
|
||||||
'border border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]';
|
|
||||||
const dashedCls =
|
|
||||||
'border border-dashed border-[color:var(--border-faint)] bg-transparent text-[color:var(--text-subtle)] hover:text-[color:var(--text-muted)] hover:border-[color:var(--border)]';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{onclick}
|
|
||||||
class="{base} {dashed ? dashedCls : active ? activeCls : neutral} {className}"
|
|
||||||
>
|
|
||||||
{#if icon}<span class="flex shrink-0 items-center">{@render icon()}</span>{/if}
|
|
||||||
<span class="truncate"
|
|
||||||
>{label}{#if value}<span class="ml-1 opacity-80">: {value}</span>{/if}</span
|
|
||||||
>
|
|
||||||
{#if active && onclear}
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabindex="-1"
|
|
||||||
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onclear(e);
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.stopPropagation();
|
|
||||||
onclear(/** @type {any} */ (e));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Clear filter"
|
|
||||||
>
|
|
||||||
<X class="size-3 stroke-[2]" />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Filter } from '@lucide/svelte';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* activeCount?: number,
|
|
||||||
* onClear?: () => void,
|
|
||||||
* children?: import('svelte').Snippet
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { activeCount = 0, onClear, children } = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger asChild class="">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<Button variant="outline" {...props}>
|
|
||||||
<Filter class="mr-2 h-4 w-4" />
|
|
||||||
Filters
|
|
||||||
{#if activeCount > 0}
|
|
||||||
<Badge variant="secondary" class="ml-2">{activeCount}</Badge>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content align="end" class="w-72">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h4 class="text-sm font-medium">Filters</h4>
|
|
||||||
{@render children?.()}
|
|
||||||
{#if onClear}
|
|
||||||
<Button variant="ghost" onclick={onClear} class="w-full">Clear Filters</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script>
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* meta?: import('svelte').Snippet,
|
|
||||||
* class?: string,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { children, meta, class: className = '' } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 border-b border-[color:var(--border-faint)] px-7 py-2.5 md:px-8 {className}"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
{#if meta}
|
|
||||||
<div class="ml-auto flex items-center gap-2 text-[13px] text-[color:var(--text-subtle)]">
|
|
||||||
{@render meta()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<script>
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { asInternalPath } from '#lib/utils/paths.js';
|
|
||||||
import { ChevronRight } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} BreadcrumbItem
|
|
||||||
* @property {string} label
|
|
||||||
* @property {string} [href]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {string} title
|
|
||||||
* @property {string} [subtitle]
|
|
||||||
* @property {'page' | 'display'} [size]
|
|
||||||
* @property {string} [eyebrow]
|
|
||||||
* @property {BreadcrumbItem[]} [breadcrumb]
|
|
||||||
* @property {import('svelte').Snippet} [titleIcon]
|
|
||||||
* @property {import('svelte').Snippet} [meta]
|
|
||||||
* @property {import('svelte').Snippet} [amount]
|
|
||||||
* @property {import('svelte').Snippet} [actions]
|
|
||||||
* @property {import('svelte').Snippet} [tabs]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
subtitle = '',
|
|
||||||
size = 'page',
|
|
||||||
eyebrow = '',
|
|
||||||
breadcrumb = [],
|
|
||||||
titleIcon,
|
|
||||||
meta,
|
|
||||||
amount,
|
|
||||||
actions,
|
|
||||||
tabs
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex flex-col gap-3 bg-[color:var(--bg)] px-7 pt-6 md:px-8 {tabs
|
|
||||||
? 'pb-0'
|
|
||||||
: 'pb-4'}"
|
|
||||||
>
|
|
||||||
{#if breadcrumb.length > 0}
|
|
||||||
<nav
|
|
||||||
aria-label="Breadcrumb"
|
|
||||||
class="flex items-center gap-1 text-[13px] leading-none text-[color:var(--text-subtle)]"
|
|
||||||
>
|
|
||||||
{#each breadcrumb as crumb, i (i)}
|
|
||||||
{#if i > 0}
|
|
||||||
<ChevronRight class="size-[14px] shrink-0 stroke-[1.6]" aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
{#if crumb.href && i < breadcrumb.length - 1}
|
|
||||||
<a
|
|
||||||
href={resolve(asInternalPath(crumb.href))}
|
|
||||||
class="rounded-sm transition-colors hover:text-[color:var(--text-muted)] focus-visible:ring-1 focus-visible:ring-[color:var(--ring)] focus-visible:outline-none"
|
|
||||||
>{crumb.label}</a
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class={i === breadcrumb.length - 1 ? 'text-[color:var(--text-muted)]' : ''}
|
|
||||||
>{crumb.label}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex min-h-[44px] items-start justify-between gap-6">
|
|
||||||
<div class="flex min-w-0 flex-1 items-start gap-3">
|
|
||||||
{#if titleIcon}
|
|
||||||
<div
|
|
||||||
class="flex size-7 shrink-0 items-center justify-center rounded-[var(--r-md)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]"
|
|
||||||
>
|
|
||||||
{@render titleIcon()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
||||||
{#if eyebrow}
|
|
||||||
<p class="label-tiny">{eyebrow}</p>
|
|
||||||
{/if}
|
|
||||||
<h1 class={size === 'display' ? 'h-display truncate' : 'h-page truncate'}>{title}</h1>
|
|
||||||
{#if subtitle}
|
|
||||||
<p class="t-body-sm truncate">{subtitle}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if amount || actions}
|
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
|
||||||
{#if amount}
|
|
||||||
<div class="flex flex-col items-end gap-0.5">
|
|
||||||
{@render amount()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if actions}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{@render actions()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if meta}
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-[color:var(--text-muted)]">
|
|
||||||
{@render meta()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tabs}
|
|
||||||
<div class="-mx-7 mt-1 border-t border-[color:var(--border-faint)] px-7 md:-mx-8 md:px-8">
|
|
||||||
{@render tabs()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<script>
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* views: { id: string, label: string, count?: number }[],
|
|
||||||
* active?: string,
|
|
||||||
* onSelect?: (id: string) => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { views, active = views[0]?.id, onSelect } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1 overflow-x-auto" role="tablist" aria-label="Views">
|
|
||||||
{#each views as v (v.id)}
|
|
||||||
{@const isActive = v.id === active}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
onclick={() => onSelect?.(v.id)}
|
|
||||||
class="-mb-px inline-flex items-center gap-1.5 px-3 pt-2 pb-[11px] text-[14px] leading-none transition-colors focus-visible:ring-1 focus-visible:ring-[color:var(--ring)] focus-visible:outline-none {isActive
|
|
||||||
? 'border-b-2 border-[color:var(--text)] font-medium text-[color:var(--text)]'
|
|
||||||
: 'border-b-2 border-transparent text-[color:var(--text-muted)] hover:text-[color:var(--text)]'}"
|
|
||||||
>
|
|
||||||
<span>{v.label}</span>
|
|
||||||
{#if v.count != null}
|
|
||||||
<span
|
|
||||||
class="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--r-sm)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-1.5 text-[11px] text-[color:var(--text-muted)] tabular-nums"
|
|
||||||
>
|
|
||||||
{v.count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export { default as FilterPopover } from './FilterPopover.svelte';
|
|
||||||
export { default as PageHeader } from './PageHeader.svelte';
|
|
||||||
export { default as FilterPill } from './FilterPill.svelte';
|
|
||||||
export { default as FilterStrip } from './FilterStrip.svelte';
|
|
||||||
export { default as ViewTabs } from './ViewTabs.svelte';
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import { buttonVariants } from '#lib/components/ui/button/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Action
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-action"
|
|
||||||
class={cn(buttonVariants(), className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import { buttonVariants } from '#lib/components/ui/button/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Cancel
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-cancel"
|
|
||||||
class={cn(buttonVariants({ variant: 'outline' }), className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* portalProps?: Record<string, any>,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
portalProps = {},
|
|
||||||
children,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
|
||||||
<AlertDialogOverlay />
|
|
||||||
<AlertDialogPrimitive.Content
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-content"
|
|
||||||
class={cn(
|
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</AlertDialogPrimitive.Content>
|
|
||||||
</AlertDialogPrimitive.Portal>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Description
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-description"
|
|
||||||
class={cn('text-muted-foreground text-sm', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="alert-dialog-footer"
|
|
||||||
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="alert-dialog-header"
|
|
||||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Overlay
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-overlay"
|
|
||||||
class={cn(
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Title
|
|
||||||
bind:ref
|
|
||||||
data-slot="alert-dialog-title"
|
|
||||||
class={cn('text-lg font-semibold', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<script>
|
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
|
||||||
import Trigger from './alert-dialog-trigger.svelte';
|
|
||||||
import Overlay from './alert-dialog-overlay.svelte';
|
|
||||||
import Content from './alert-dialog-content.svelte';
|
|
||||||
import Header from './alert-dialog-header.svelte';
|
|
||||||
import Footer from './alert-dialog-footer.svelte';
|
|
||||||
import Title from './alert-dialog-title.svelte';
|
|
||||||
import Description from './alert-dialog-description.svelte';
|
|
||||||
import Cancel from './alert-dialog-cancel.svelte';
|
|
||||||
import Action from './alert-dialog-action.svelte';
|
|
||||||
|
|
||||||
const Root = AlertDialogPrimitive.Root;
|
|
||||||
const Portal = AlertDialogPrimitive.Portal;
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Trigger,
|
|
||||||
Portal,
|
|
||||||
Overlay,
|
|
||||||
Content,
|
|
||||||
Header,
|
|
||||||
Footer,
|
|
||||||
Title,
|
|
||||||
Description,
|
|
||||||
Cancel,
|
|
||||||
Action,
|
|
||||||
//
|
|
||||||
Root as AlertDialog,
|
|
||||||
Trigger as AlertDialogTrigger,
|
|
||||||
Portal as AlertDialogPortal,
|
|
||||||
Overlay as AlertDialogOverlay,
|
|
||||||
Content as AlertDialogContent,
|
|
||||||
Header as AlertDialogHeader,
|
|
||||||
Footer as AlertDialogFooter,
|
|
||||||
Title as AlertDialogTitle,
|
|
||||||
Description as AlertDialogDescription,
|
|
||||||
Cancel as AlertDialogCancel,
|
|
||||||
Action as AlertDialogAction
|
|
||||||
};
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { gradientFor, WORKSPACE_GRADIENT } from './avatar-gradients.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* gradientSeed?: string | number,
|
|
||||||
* workspace?: boolean,
|
|
||||||
* style?: string,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
gradientSeed = undefined,
|
|
||||||
workspace = false,
|
|
||||||
style: callerStyle = '',
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const bg = $derived(workspace ? WORKSPACE_GRADIENT : gradientFor(gradientSeed));
|
|
||||||
const styleValue = $derived(`background-image: ${bg};${callerStyle ? ' ' + callerStyle : ''}`);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AvatarPrimitive.Fallback
|
|
||||||
bind:ref
|
|
||||||
data-slot="avatar-fallback"
|
|
||||||
class={cn(
|
|
||||||
'flex size-full items-center justify-center rounded-full',
|
|
||||||
'leading-none font-semibold text-white',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={styleValue}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
export const AVATAR_GRADIENTS = [
|
|
||||||
'linear-gradient(135deg, #a78bfa, #7c3aed)', // 0 purple
|
|
||||||
'linear-gradient(135deg, #f472b6, #db2777)', // 1 pink
|
|
||||||
'linear-gradient(135deg, #34d399, #059669)', // 2 green
|
|
||||||
'linear-gradient(135deg, #60a5fa, #2563eb)', // 3 blue
|
|
||||||
'linear-gradient(135deg, #fbbf24, #ef4444)', // 4 orange-red
|
|
||||||
'linear-gradient(135deg, #67e8f9, #0891b2)', // 5 cyan
|
|
||||||
'linear-gradient(135deg, #fcd34d, #d97706)' // 6 amber
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WORKSPACE_GRADIENT = 'linear-gradient(135deg, #ea580c, #c2410c)';
|
|
||||||
|
|
||||||
// Stable string hash → bucket [0,6]. djb2-ish; only needs to be deterministic & well-spread.
|
|
||||||
export function gradientFor(seed) {
|
|
||||||
if (seed == null) return AVATAR_GRADIENTS[0];
|
|
||||||
const s = String(seed);
|
|
||||||
let h = 5381;
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return AVATAR_GRADIENTS[Math.abs(h) % AVATAR_GRADIENTS.length];
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AvatarPrimitive.Image
|
|
||||||
bind:ref
|
|
||||||
data-slot="avatar-image"
|
|
||||||
class={cn('aspect-square size-full', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {'loading' | 'loaded' | 'error'} LoadingStatus
|
|
||||||
* @typedef {'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'} AvatarSize
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* loadingStatus?: LoadingStatus,
|
|
||||||
* size?: AvatarSize,
|
|
||||||
* class?: string,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
loadingStatus = $bindable(/** @type {LoadingStatus} */ ('loading')),
|
|
||||||
size = 'md',
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Spec §6.5: 18 / 22 / 24 / 28 / 32 / 44.
|
|
||||||
const sizeClass = $derived(
|
|
||||||
{
|
|
||||||
xs: 'size-[18px] text-[8px]',
|
|
||||||
sm: 'size-[22px] text-[9px]',
|
|
||||||
md: 'size-[24px] text-[10px]',
|
|
||||||
lg: 'size-[28px] text-[11px]',
|
|
||||||
xl: 'size-[32px] text-[12px]',
|
|
||||||
'2xl': 'size-[44px] text-[14px]'
|
|
||||||
}[size] || 'size-[24px] text-[10px]'
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AvatarPrimitive.Root
|
|
||||||
bind:ref
|
|
||||||
bind:loadingStatus
|
|
||||||
data-slot="avatar"
|
|
||||||
data-size={size}
|
|
||||||
class={cn('relative flex shrink-0 overflow-hidden rounded-full', sizeClass, className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import Root from './avatar.svelte';
|
|
||||||
import Image from './avatar-image.svelte';
|
|
||||||
import Fallback from './avatar-fallback.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Image,
|
|
||||||
Fallback,
|
|
||||||
//
|
|
||||||
Root as Avatar,
|
|
||||||
Image as AvatarImage,
|
|
||||||
Fallback as AvatarFallback
|
|
||||||
};
|
|
||||||
export { gradientFor, AVATAR_GRADIENTS, WORKSPACE_GRADIENT } from './avatar-gradients.js';
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
<script module>
|
|
||||||
import { tv } from 'tailwind-variants';
|
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
|
||||||
base: [
|
|
||||||
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap',
|
|
||||||
'rounded-full border text-[10px] font-medium leading-none',
|
|
||||||
'transition-[color,background-color,border-color] duration-150',
|
|
||||||
'focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
|
|
||||||
'[&>svg]:pointer-events-none [&>svg]:size-3'
|
|
||||||
].join(' '),
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
// Legacy shadcn variants, kept for back-compat with existing callers.
|
|
||||||
default:
|
|
||||||
'bg-primary text-primary-foreground border-transparent px-2 py-0.5 [a&]:hover:bg-primary/90',
|
|
||||||
secondary:
|
|
||||||
'bg-secondary text-secondary-foreground border-transparent px-2 py-0.5 [a&]:hover:bg-secondary/90',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive text-white border-transparent px-2 py-0.5 [a&]:hover:bg-destructive/90 dark:bg-destructive/70',
|
|
||||||
outline:
|
|
||||||
'text-foreground border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-2 py-0.5',
|
|
||||||
|
|
||||||
// New design-system tag variants, spec §6.4.
|
|
||||||
customer:
|
|
||||||
'bg-[color:var(--green-soft)] text-[color:var(--green-soft-text)] border-transparent px-[7px] py-px',
|
|
||||||
vip: 'bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)] border-transparent px-[7px] py-px',
|
|
||||||
meeting:
|
|
||||||
'bg-[color:var(--amber-soft)] text-[color:var(--amber-soft-text)] border-transparent px-[7px] py-px',
|
|
||||||
neutral:
|
|
||||||
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] border-[color:var(--border-faint)] px-[7px] py-px',
|
|
||||||
count:
|
|
||||||
'bg-[color:var(--bg-elevated)] text-[color:var(--text-subtle)] border-[color:var(--border-faint)] px-[5px] py-px text-[10px]'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Variants that render the 5px dot prefix (every spec variant except `count`).
|
|
||||||
const DOT_VARIANTS = new Set(['customer', 'vip', 'meeting', 'neutral']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* href?: string,
|
|
||||||
* class?: string,
|
|
||||||
* variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'customer' | 'vip' | 'meeting' | 'neutral' | 'count',
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
href = undefined,
|
|
||||||
class: className,
|
|
||||||
variant = 'default',
|
|
||||||
children,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const showDot = $derived(DOT_VARIANTS.has(variant));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:element
|
|
||||||
this={href ? 'a' : 'span'}
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="badge"
|
|
||||||
data-variant={variant}
|
|
||||||
{href}
|
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{#if showDot}
|
|
||||||
<span aria-hidden="true" class="inline-block size-[5px] shrink-0 rounded-full bg-current"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
{@render children?.()}
|
|
||||||
</svelte:element>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { default as Badge } from './badge.svelte';
|
|
||||||
export { badgeVariants } from './badge.svelte';
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<script>
|
|
||||||
import { X } from '@lucide/svelte';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
|
|
||||||
/** @typedef {{ label: string, onClick: () => void, variant?: 'default'|'outline'|'destructive' }} BulkAction */
|
|
||||||
|
|
||||||
/** @type {{ count: number, actions?: BulkAction[], onClear: () => void }} */
|
|
||||||
let { count, actions = [], onClear } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if count > 0}
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-label="Bulk actions"
|
|
||||||
class="fixed inset-x-0 bottom-4 z-40 mx-auto flex w-fit items-center gap-2 rounded-full border border-[var(--border-default)] bg-[var(--surface-default)] px-4 py-2 shadow-lg"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-medium">{count} selected</span>
|
|
||||||
<span class="h-4 w-px bg-[var(--border-default)]"></span>
|
|
||||||
{#each actions as a (a.label)}
|
|
||||||
<Button size="sm" variant={a.variant || 'outline'} onclick={a.onClick}>{a.label}</Button>
|
|
||||||
{/each}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onClear}
|
|
||||||
class="ml-1 rounded-full p-1 hover:bg-[var(--surface-sunken)]"
|
|
||||||
aria-label="Clear selection"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as BulkActionBar } from './BulkActionBar.svelte';
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Calendar } from 'bits-ui';
|
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { today, getLocalTimeZone } from '@internationalized/date';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* value?: import('@internationalized/date').DateValue | undefined,
|
|
||||||
* onValueChange?: (value: import('@internationalized/date').DateValue | undefined) => void,
|
|
||||||
* class?: string,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { value = $bindable(), onValueChange, class: className } = $props();
|
|
||||||
|
|
||||||
// Placeholder controls which month/year is displayed
|
|
||||||
let placeholder = $state(value || today(getLocalTimeZone()));
|
|
||||||
|
|
||||||
// Sync placeholder when value changes
|
|
||||||
$effect(() => {
|
|
||||||
if (value) {
|
|
||||||
placeholder = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate year options (100 years back, 10 years forward)
|
|
||||||
const currentYear = today(getLocalTimeZone()).year;
|
|
||||||
const years = Array.from({ length: 111 }, (_, i) => currentYear - 100 + i);
|
|
||||||
|
|
||||||
const months = [
|
|
||||||
'January',
|
|
||||||
'February',
|
|
||||||
'March',
|
|
||||||
'April',
|
|
||||||
'May',
|
|
||||||
'June',
|
|
||||||
'July',
|
|
||||||
'August',
|
|
||||||
'September',
|
|
||||||
'October',
|
|
||||||
'November',
|
|
||||||
'December'
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Calendar.Root
|
|
||||||
type="single"
|
|
||||||
weekdayFormat="short"
|
|
||||||
fixedWeeks={true}
|
|
||||||
bind:value
|
|
||||||
bind:placeholder
|
|
||||||
{onValueChange}
|
|
||||||
class={cn('p-3', className)}
|
|
||||||
>
|
|
||||||
{#snippet children({ months: calendarMonths, weekdays })}
|
|
||||||
<Calendar.Header class="relative flex w-full items-center justify-between gap-1 pb-2">
|
|
||||||
<Calendar.PrevButton
|
|
||||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-gray-200 bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Calendar.PrevButton>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<!-- Month Select -->
|
|
||||||
<select
|
|
||||||
class="h-7 rounded-md border border-gray-200 bg-transparent px-2 text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700"
|
|
||||||
value={placeholder?.month}
|
|
||||||
onchange={(e) => {
|
|
||||||
const target = /** @type {HTMLSelectElement} */ (e.target);
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder = placeholder.set({ month: parseInt(target.value) });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#each months as month, i (month)}
|
|
||||||
<option value={i + 1}>{month}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Year Select -->
|
|
||||||
<select
|
|
||||||
class="h-7 rounded-md border border-gray-200 bg-transparent px-2 text-sm font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700"
|
|
||||||
value={placeholder?.year}
|
|
||||||
onchange={(e) => {
|
|
||||||
const target = /** @type {HTMLSelectElement} */ (e.target);
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder = placeholder.set({ year: parseInt(target.value) });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#each years as year (year)}
|
|
||||||
<option value={year}>{year}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Calendar.NextButton
|
|
||||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-gray-200 bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Calendar.NextButton>
|
|
||||||
</Calendar.Header>
|
|
||||||
|
|
||||||
{#each calendarMonths as month (month.value.toString())}
|
|
||||||
<Calendar.Grid class="w-full border-collapse space-y-1">
|
|
||||||
<Calendar.GridHead>
|
|
||||||
<Calendar.GridRow class="flex">
|
|
||||||
{#each weekdays as day (day)}
|
|
||||||
<Calendar.HeadCell
|
|
||||||
class="w-8 rounded-md text-[0.8rem] font-normal text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
{day.slice(0, 2)}
|
|
||||||
</Calendar.HeadCell>
|
|
||||||
{/each}
|
|
||||||
</Calendar.GridRow>
|
|
||||||
</Calendar.GridHead>
|
|
||||||
<Calendar.GridBody>
|
|
||||||
{#each month.weeks as week, weekIndex (weekIndex)}
|
|
||||||
<Calendar.GridRow class="mt-2 flex w-full">
|
|
||||||
{#each week as date (date.toString())}
|
|
||||||
<Calendar.Cell {date} month={month.value} class="relative p-0 text-center text-sm">
|
|
||||||
<Calendar.Day
|
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md p-0 text-sm font-normal transition-colors hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[disabled]:text-gray-400 data-[outside-month]:text-gray-400 data-[outside-month]:opacity-50 data-[selected]:bg-blue-600 data-[selected]:text-white data-[today]:bg-gray-100 data-[today]:font-semibold dark:hover:bg-gray-800 dark:data-[selected]:bg-blue-600 dark:data-[today]:bg-gray-800"
|
|
||||||
>
|
|
||||||
{date.day}
|
|
||||||
</Calendar.Day>
|
|
||||||
</Calendar.Cell>
|
|
||||||
{/each}
|
|
||||||
</Calendar.GridRow>
|
|
||||||
{/each}
|
|
||||||
</Calendar.GridBody>
|
|
||||||
</Calendar.Grid>
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
</Calendar.Root>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import Calendar from './Calendar.svelte';
|
|
||||||
|
|
||||||
export { Calendar };
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
/** @type {{ ref?: HTMLElement | null, class?: string, children?: import('svelte').Snippet, [key: string]: any }} */
|
|
||||||
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="card-content"
|
|
||||||
class={cn('px-4 py-3.5 text-[13px] leading-[1.55] text-[color:var(--text-muted)]', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<p
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="card-description"
|
|
||||||
class={cn('text-muted-foreground text-sm', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</p>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="card-header"
|
|
||||||
class={cn(
|
|
||||||
'@container/card-header flex items-center justify-between gap-2',
|
|
||||||
'border-b border-[color:var(--border-faint)] px-4 py-3',
|
|
||||||
'has-data-[slot=card-action]:grid-cols-[1fr_auto]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="card-title"
|
|
||||||
class={cn(
|
|
||||||
'text-[10px] leading-none font-medium tracking-[0.06em] text-[color:var(--text-muted)] uppercase',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="card"
|
|
||||||
class={cn(
|
|
||||||
'flex flex-col overflow-hidden',
|
|
||||||
'rounded-[var(--r-lg)] border border-[color:var(--border-faint)]',
|
|
||||||
'bg-[color:var(--bg-card)] text-[color:var(--text)]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import Root from './card.svelte';
|
|
||||||
import Content from './card-content.svelte';
|
|
||||||
import Description from './card-description.svelte';
|
|
||||||
import Header from './card-header.svelte';
|
|
||||||
import Title from './card-title.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Content,
|
|
||||||
Description,
|
|
||||||
Header,
|
|
||||||
Title,
|
|
||||||
//
|
|
||||||
Root as Card,
|
|
||||||
Content as CardContent,
|
|
||||||
Description as CardDescription,
|
|
||||||
Header as CardHeader,
|
|
||||||
Title as CardTitle
|
|
||||||
};
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
<script>
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { asInternalPath } from '#lib/utils/paths.js';
|
|
||||||
import { X, Trash2, Maximize2 } from '@lucide/svelte';
|
|
||||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
|
||||||
import { Skeleton } from '#lib/components/ui/skeleton/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import CrmPropertyRow from './CrmPropertyRow.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* open?: boolean,
|
|
||||||
* onOpenChange?: (open: boolean) => void,
|
|
||||||
* data?: Record<string, any> | null,
|
|
||||||
* columns?: any[],
|
|
||||||
* titleKey?: string,
|
|
||||||
* titlePlaceholder?: string,
|
|
||||||
* titleEditable?: boolean,
|
|
||||||
* headerLabel?: string,
|
|
||||||
* onFieldChange?: (field: string, value: any) => void,
|
|
||||||
* onDelete?: () => void,
|
|
||||||
* onClose?: () => void,
|
|
||||||
* loading?: boolean,
|
|
||||||
* mode?: 'view' | 'create',
|
|
||||||
* fullPageHref?: string,
|
|
||||||
* class?: string,
|
|
||||||
* metaSection?: import('svelte').Snippet,
|
|
||||||
* banner?: import('svelte').Snippet,
|
|
||||||
* activitySection?: import('svelte').Snippet,
|
|
||||||
* footerActions?: import('svelte').Snippet,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
open = $bindable(false),
|
|
||||||
onOpenChange,
|
|
||||||
data = null,
|
|
||||||
columns = [],
|
|
||||||
titleKey = 'name',
|
|
||||||
titlePlaceholder = 'Untitled',
|
|
||||||
titleEditable = true,
|
|
||||||
headerLabel = 'Item',
|
|
||||||
onFieldChange,
|
|
||||||
onDelete,
|
|
||||||
onClose,
|
|
||||||
loading = false,
|
|
||||||
mode = 'view',
|
|
||||||
fullPageHref = '',
|
|
||||||
class: className,
|
|
||||||
metaSection,
|
|
||||||
banner,
|
|
||||||
activitySection,
|
|
||||||
footerActions
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get field value from data
|
|
||||||
* @param {any} col
|
|
||||||
*/
|
|
||||||
function getFieldValue(col) {
|
|
||||||
if (!data) return '';
|
|
||||||
if (col.getValue) {
|
|
||||||
return col.getValue(data);
|
|
||||||
}
|
|
||||||
return data[col.key] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle field change
|
|
||||||
* @param {string} key
|
|
||||||
* @param {any} value
|
|
||||||
*/
|
|
||||||
function handleFieldChange(key, value) {
|
|
||||||
onFieldChange?.(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle title change
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
function handleTitleChange(e) {
|
|
||||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
|
||||||
onFieldChange?.(titleKey, target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the drawer
|
|
||||||
*/
|
|
||||||
function closeDrawer() {
|
|
||||||
open = false;
|
|
||||||
onOpenChange?.(false);
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle delete
|
|
||||||
*/
|
|
||||||
function handleDelete() {
|
|
||||||
onDelete?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get title value
|
|
||||||
const titleValue = $derived(data?.[titleKey] || '');
|
|
||||||
|
|
||||||
// Stable id for ARIA labelling, Sheet.Content references this so screen readers
|
|
||||||
// announce the lead title when the dialog opens.
|
|
||||||
const titleId = $derived(`crm-drawer-title-${data?.id ?? 'new'}`);
|
|
||||||
|
|
||||||
// All non-title fields to render. In create mode, hide fields marked hideOnCreate
|
|
||||||
// (typically read-only system fields like created/closed dates).
|
|
||||||
const visibleColumns = $derived(
|
|
||||||
columns.filter((col) => {
|
|
||||||
if (col.key === titleKey) return false;
|
|
||||||
if (mode === 'create' && col.hideOnCreate) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Sheet.Root bind:open onOpenChange={(value) => onOpenChange?.(value)}>
|
|
||||||
<Sheet.Content
|
|
||||||
side="right"
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
class={cn(
|
|
||||||
'border-border/50 bg-background/95 w-[480px] overflow-hidden border-l p-0 backdrop-blur-xl sm:max-w-[480px]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<!-- Loading skeleton with premium styling -->
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<!-- Header skeleton -->
|
|
||||||
<div class="border-border/40 relative border-b px-6 py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Skeleton class="h-4 w-16 rounded-md" />
|
|
||||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content skeleton -->
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<div class="px-6 pt-8 pb-6">
|
|
||||||
<Skeleton class="h-9 w-64 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2 px-6 pb-8">
|
|
||||||
{#each { length: 6 } as _, index (index)}
|
|
||||||
<div class="bg-muted/20 flex items-center gap-4 rounded-lg px-3 py-3">
|
|
||||||
<Skeleton class="h-6 w-6 rounded-md" />
|
|
||||||
<Skeleton class="h-4 w-24" />
|
|
||||||
<Skeleton class="ml-auto h-5 w-32 rounded-md" />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<!-- Premium Header -->
|
|
||||||
<div
|
|
||||||
class="border-border/40 from-muted/30 relative border-b bg-gradient-to-b to-transparent"
|
|
||||||
>
|
|
||||||
<!-- Ambient glow -->
|
|
||||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
|
||||||
<div
|
|
||||||
class="bg-primary/5 absolute -top-12 left-1/4 h-24 w-48 rounded-full blur-2xl"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative flex items-center justify-between px-6 py-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="bg-primary/10 text-primary rounded-md px-2 py-1 text-xs font-semibold tracking-wider uppercase"
|
|
||||||
>
|
|
||||||
{headerLabel}
|
|
||||||
</span>
|
|
||||||
{#if mode === 'create'}
|
|
||||||
<span class="text-muted-foreground text-xs">New</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{#if fullPageHref && mode !== 'create'}
|
|
||||||
<a
|
|
||||||
href={resolve(asInternalPath(fullPageHref))}
|
|
||||||
aria-label="Open full page"
|
|
||||||
title="Open full page"
|
|
||||||
class="text-muted-foreground hover:bg-muted/60 hover:text-foreground flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150"
|
|
||||||
>
|
|
||||||
<Maximize2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={closeDrawer}
|
|
||||||
aria-label="Close"
|
|
||||||
class="text-muted-foreground hover:bg-muted/60 hover:text-foreground flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
|
||||||
<div class="flex-1 overflow-x-hidden overflow-y-auto">
|
|
||||||
{#if banner}
|
|
||||||
{@render banner()}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Title section with elegant styling -->
|
|
||||||
<div class="px-6 pt-8 pb-6">
|
|
||||||
{#if titleEditable}
|
|
||||||
<input
|
|
||||||
id={titleId}
|
|
||||||
type="text"
|
|
||||||
value={titleValue}
|
|
||||||
oninput={handleTitleChange}
|
|
||||||
placeholder={titlePlaceholder}
|
|
||||||
aria-label="{headerLabel} title"
|
|
||||||
class="text-foreground placeholder:text-muted-foreground/30 hover:bg-muted/40 focus:bg-muted/40 focus:ring-primary/20 -mx-2 w-[calc(100%+1rem)] rounded-md border-0 bg-transparent px-2 py-1 text-2xl font-bold tracking-tight transition-colors outline-none focus:ring-1"
|
|
||||||
style="letter-spacing: -0.025em;"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<h2
|
|
||||||
id={titleId}
|
|
||||||
class="text-foreground text-2xl font-bold tracking-tight"
|
|
||||||
style="letter-spacing: -0.025em;"
|
|
||||||
>
|
|
||||||
{titleValue || titlePlaceholder}
|
|
||||||
</h2>
|
|
||||||
{/if}
|
|
||||||
{#if metaSection && mode !== 'create'}
|
|
||||||
<div class="mt-3">
|
|
||||||
{@render metaSection()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Properties section with refined spacing -->
|
|
||||||
<div class="px-6 pb-8">
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#each visibleColumns as col (col.key)}
|
|
||||||
<CrmPropertyRow
|
|
||||||
label={col.label}
|
|
||||||
value={getFieldValue(col)}
|
|
||||||
type={col.type || 'text'}
|
|
||||||
icon={col.icon}
|
|
||||||
options={col.options}
|
|
||||||
placeholder={col.placeholder}
|
|
||||||
emptyText={col.emptyText}
|
|
||||||
editable={col.editable !== false}
|
|
||||||
required={col.required === true}
|
|
||||||
prefix={col.prefix}
|
|
||||||
loading={col.loading === true}
|
|
||||||
loadingError={col.loadingError || ''}
|
|
||||||
onRetry={col.onRetry}
|
|
||||||
onchange={(value) => handleFieldChange(col.key, value)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activity section with refined styling -->
|
|
||||||
{#if activitySection}
|
|
||||||
<div
|
|
||||||
class="via-border mx-6 h-px bg-gradient-to-r from-transparent to-transparent"
|
|
||||||
></div>
|
|
||||||
<div class="px-6 py-6">
|
|
||||||
{@render activitySection()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Footer -->
|
|
||||||
<div
|
|
||||||
class="border-border/40 from-muted/20 relative mt-auto border-t bg-gradient-to-t to-transparent"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between px-6 py-4">
|
|
||||||
<!-- Delete button -->
|
|
||||||
{#if onDelete && mode !== 'create'}
|
|
||||||
<button
|
|
||||||
onclick={handleDelete}
|
|
||||||
class="group text-destructive hover:bg-destructive/10 flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 transition-transform group-hover:scale-110" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Custom footer actions -->
|
|
||||||
{#if footerActions}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{@render footerActions()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Sheet.Content>
|
|
||||||
</Sheet.Root>
|
|
||||||
|
|
@ -1,482 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Check, Calendar as CalendarIcon } from '@lucide/svelte';
|
|
||||||
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
import { Calendar } from '#lib/components/ui/calendar/index.js';
|
|
||||||
import { EditableMultiSelect } from '#lib/components/ui/editable-field/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { formatCurrency } from '#lib/utils/formatting.js';
|
|
||||||
import { parseDate } from '@internationalized/date';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* label: string,
|
|
||||||
* value?: any,
|
|
||||||
* type?: 'text' | 'email' | 'number' | 'date' | 'select' | 'checkbox' | 'boolean' | 'multiselect' | 'textarea' | 'color' | 'readonly',
|
|
||||||
* icon?: import('svelte').Component,
|
|
||||||
* options?: any[],
|
|
||||||
* placeholder?: string,
|
|
||||||
* emptyText?: string,
|
|
||||||
* editable?: boolean,
|
|
||||||
* required?: boolean,
|
|
||||||
* error?: string,
|
|
||||||
* id?: string,
|
|
||||||
* prefix?: string,
|
|
||||||
* loading?: boolean,
|
|
||||||
* loadingError?: string,
|
|
||||||
* onRetry?: () => void,
|
|
||||||
* onchange?: (value: any) => void,
|
|
||||||
* class?: string,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
value = '',
|
|
||||||
type = 'text',
|
|
||||||
icon: Icon,
|
|
||||||
options = [],
|
|
||||||
placeholder = '',
|
|
||||||
emptyText = '',
|
|
||||||
editable = true,
|
|
||||||
required = false,
|
|
||||||
error = '',
|
|
||||||
id = '',
|
|
||||||
prefix = '',
|
|
||||||
loading = false,
|
|
||||||
loadingError = '',
|
|
||||||
onRetry,
|
|
||||||
onchange,
|
|
||||||
class: className
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Stable id for ARIA wiring; falls back to a slug of the label
|
|
||||||
const inputId = $derived(
|
|
||||||
id ||
|
|
||||||
`prop-${label
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')}`
|
|
||||||
);
|
|
||||||
const labelId = $derived(`${inputId}-label`);
|
|
||||||
const errorId = $derived(`${inputId}-error`);
|
|
||||||
|
|
||||||
// Plain-text rendering for read-only text-like fields
|
|
||||||
const isPlainReadonly = $derived(
|
|
||||||
!editable && (type === 'text' || type === 'email' || type === 'number' || type === 'textarea')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Read-only date renders as plain formatted text instead of a disabled-looking button
|
|
||||||
const isReadonlyDate = $derived(!editable && type === 'date');
|
|
||||||
|
|
||||||
// Read-only select renders the colored badge as a static span (no dropdown chrome)
|
|
||||||
const isReadonlySelect = $derived(!editable && type === 'select');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get option style for select
|
|
||||||
* @param {string} val
|
|
||||||
*/
|
|
||||||
function getOptionStyle(val) {
|
|
||||||
if (type !== 'select') return '';
|
|
||||||
const opt = options.find((/** @type {any} */ o) => o.value === val);
|
|
||||||
return opt?.color || 'bg-gray-100 text-gray-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get option label for select
|
|
||||||
* @param {string} val
|
|
||||||
*/
|
|
||||||
function getOptionLabel(val) {
|
|
||||||
if (type !== 'select') return val;
|
|
||||||
const opt = options.find((/** @type {any} */ o) => o.value === val);
|
|
||||||
return opt?.label || val || emptyText || 'Select...';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get option background color class (for the dot)
|
|
||||||
* @param {string} val
|
|
||||||
*/
|
|
||||||
function getOptionBgColor(val) {
|
|
||||||
if (type !== 'select') return '';
|
|
||||||
const opt = options.find((/** @type {any} */ o) => o.value === val);
|
|
||||||
if (!opt?.color) return 'bg-gray-400';
|
|
||||||
// Extract bg class from color string (e.g., "bg-emerald-100 text-emerald-700" -> "bg-emerald-500")
|
|
||||||
const match = opt.color.match(/bg-(\w+)-\d+/);
|
|
||||||
if (match) {
|
|
||||||
return `bg-${match[1]}-500`;
|
|
||||||
}
|
|
||||||
return 'bg-gray-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse string date to DateValue
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function parseDateValue(dateStr) {
|
|
||||||
if (!dateStr) return undefined;
|
|
||||||
try {
|
|
||||||
return parseDate(dateStr);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for display. Accepts a YYYY-MM-DD string or an ISO datetime.
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function formatDateDisplay(dateStr) {
|
|
||||||
if (!dateStr) return placeholder || 'Pick a date';
|
|
||||||
try {
|
|
||||||
// If we got just YYYY-MM-DD, pin it to local midnight; otherwise let Date parse the ISO string.
|
|
||||||
const isoLike = /T|\s/.test(dateStr) ? dateStr : `${dateStr}T00:00:00`;
|
|
||||||
const date = new Date(isoLike);
|
|
||||||
if (Number.isNaN(date.getTime())) return dateStr;
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle calendar date change
|
|
||||||
* @param {import('@internationalized/date').DateValue | undefined} dateValue
|
|
||||||
*/
|
|
||||||
function handleCalendarChange(dateValue) {
|
|
||||||
if (dateValue) {
|
|
||||||
onchange?.(dateValue.toString());
|
|
||||||
datePopoverOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// State for date popover
|
|
||||||
let datePopoverOpen = $state(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format number with optional currency
|
|
||||||
* @param {number | string} val
|
|
||||||
*/
|
|
||||||
function formatNumber(val) {
|
|
||||||
const num = typeof val === 'string' ? parseFloat(val) : val;
|
|
||||||
if (isNaN(num)) return emptyText || '0';
|
|
||||||
if (prefix === '$') {
|
|
||||||
return formatCurrency(num);
|
|
||||||
}
|
|
||||||
return prefix + num.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle text input
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
function handleInput(e) {
|
|
||||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
|
||||||
onchange?.(target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle number input
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
function handleNumberInput(e) {
|
|
||||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
|
||||||
onchange?.(parseFloat(target.value) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle checkbox toggle
|
|
||||||
*/
|
|
||||||
function handleCheckboxToggle() {
|
|
||||||
onchange?.(!value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle color input
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
function handleColorInput(e) {
|
|
||||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
|
||||||
onchange?.(target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle select change
|
|
||||||
* @param {string} newValue
|
|
||||||
*/
|
|
||||||
function handleSelectChange(newValue) {
|
|
||||||
onchange?.(newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle multiselect change
|
|
||||||
* @param {string[]} newValue
|
|
||||||
*/
|
|
||||||
function handleMultiSelectChange(newValue) {
|
|
||||||
onchange?.(newValue);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'group hover:bg-muted/40 -mx-3 flex min-h-[44px] items-center rounded-lg px-3 transition-all duration-150',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Label with icon. Uses <label for> so clicking focuses the input -->
|
|
||||||
<label
|
|
||||||
for={inputId}
|
|
||||||
id={labelId}
|
|
||||||
class="text-muted-foreground flex w-32 shrink-0 items-center gap-2.5 text-[13px] font-medium"
|
|
||||||
>
|
|
||||||
{#if Icon}
|
|
||||||
<div class="bg-muted/50 flex h-6 w-6 items-center justify-center rounded-md">
|
|
||||||
<Icon class="text-muted-foreground/70 h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span>
|
|
||||||
{label}
|
|
||||||
{#if required}
|
|
||||||
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Value -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
{#if isPlainReadonly}
|
|
||||||
<span
|
|
||||||
class={cn('block px-2.5 py-1.5 text-sm', type === 'textarea' && 'whitespace-pre-wrap')}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
>
|
|
||||||
{#if value !== null && value !== undefined && value !== ''}
|
|
||||||
<span class="text-foreground font-medium">
|
|
||||||
{type === 'number' ? formatNumber(value) : value}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{:else if type === 'text' || type === 'email'}
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
{type}
|
|
||||||
{value}
|
|
||||||
oninput={handleInput}
|
|
||||||
{placeholder}
|
|
||||||
disabled={!editable}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-required={required ? 'true' : undefined}
|
|
||||||
aria-invalid={error ? 'true' : undefined}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 aria-[invalid=true]:ring-destructive/40 w-full rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm font-medium transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1"
|
|
||||||
/>
|
|
||||||
{:else if type === 'number'}
|
|
||||||
<div class="flex items-center">
|
|
||||||
{#if prefix}
|
|
||||||
<span class="text-muted-foreground mr-1.5 text-sm font-medium" aria-hidden="true"
|
|
||||||
>{prefix}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
type="number"
|
|
||||||
value={value || 0}
|
|
||||||
oninput={handleNumberInput}
|
|
||||||
{placeholder}
|
|
||||||
disabled={!editable}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-required={required ? 'true' : undefined}
|
|
||||||
aria-invalid={error ? 'true' : undefined}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 aria-[invalid=true]:ring-destructive/40 w-full rounded-lg border-0 bg-transparent px-2 py-1.5 text-sm font-medium tabular-nums transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if isReadonlyDate}
|
|
||||||
<span
|
|
||||||
class="text-foreground block px-2.5 py-1.5 text-sm font-medium"
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
>
|
|
||||||
{#if value}
|
|
||||||
{formatDateDisplay(value)}
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{:else if type === 'date'}
|
|
||||||
<Popover.Root bind:open={datePopoverOpen}>
|
|
||||||
<Popover.Trigger disabled={!editable} class="w-full">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
type="button"
|
|
||||||
id={inputId}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class={cn(
|
|
||||||
'hover:bg-muted/30 inline-flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all',
|
|
||||||
!value ? 'text-muted-foreground/50' : 'text-foreground',
|
|
||||||
!editable && 'cursor-default hover:bg-transparent',
|
|
||||||
error && 'ring-destructive/40 ring-1'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarIcon
|
|
||||||
class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{formatDateDisplay(value)}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content class="w-auto p-0" align="start">
|
|
||||||
<Calendar value={parseDateValue(value)} onValueChange={handleCalendarChange} />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
{:else if type === 'textarea'}
|
|
||||||
<textarea
|
|
||||||
id={inputId}
|
|
||||||
oninput={handleInput}
|
|
||||||
{placeholder}
|
|
||||||
disabled={!editable}
|
|
||||||
rows={3}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-required={required ? 'true' : undefined}
|
|
||||||
aria-invalid={error ? 'true' : undefined}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 aria-[invalid=true]:ring-destructive/40 w-full resize-none rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1"
|
|
||||||
>{value || ''}</textarea
|
|
||||||
>
|
|
||||||
{:else if isReadonlySelect}
|
|
||||||
<span
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-semibold ring-1 ring-black/5 ring-inset dark:ring-white/10 {getOptionStyle(
|
|
||||||
value
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-2 w-2 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {getOptionBgColor(
|
|
||||||
value
|
|
||||||
)}"
|
|
||||||
></span>
|
|
||||||
{getOptionLabel(value)}
|
|
||||||
</span>
|
|
||||||
{:else if type === 'select'}
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger disabled={!editable}>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
type="button"
|
|
||||||
id={inputId}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class={cn(
|
|
||||||
'inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-semibold shadow-sm ring-1 transition-all duration-150 ring-inset hover:shadow',
|
|
||||||
error ? 'ring-destructive/40' : 'ring-black/5 dark:ring-white/10',
|
|
||||||
getOptionStyle(value)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-2 w-2 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {getOptionBgColor(
|
|
||||||
value
|
|
||||||
)}"
|
|
||||||
></span>
|
|
||||||
{getOptionLabel(value)}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="start" class="min-w-48 p-1.5">
|
|
||||||
{#each options as option (option.value)}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onclick={() => handleSelectChange(option.value)}
|
|
||||||
class="flex items-center gap-2.5 rounded-lg px-2.5 py-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-2.5 w-2.5 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {option.color?.split(
|
|
||||||
' '
|
|
||||||
)[0] || 'bg-gray-400'}"
|
|
||||||
></span>
|
|
||||||
<span class="font-medium">{option.label}</span>
|
|
||||||
{#if value === option.value}
|
|
||||||
<Check class="text-primary ml-auto h-4 w-4" aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{/each}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
{:else if type === 'checkbox' || type === 'boolean'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id={inputId}
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={value ? 'true' : 'false'}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-required={required ? 'true' : undefined}
|
|
||||||
aria-invalid={error ? 'true' : undefined}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
onclick={handleCheckboxToggle}
|
|
||||||
disabled={!editable}
|
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150 {value
|
|
||||||
? 'border-primary bg-primary shadow-primary/25 shadow-sm'
|
|
||||||
: 'border-muted-foreground/30 hover:border-muted-foreground/50'}"
|
|
||||||
>
|
|
||||||
{#if value}
|
|
||||||
<Check class="text-primary-foreground h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else if type === 'color'}
|
|
||||||
<div class="flex items-center gap-3 px-2 py-1">
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
type="color"
|
|
||||||
value={value || '#3B82F6'}
|
|
||||||
oninput={handleColorInput}
|
|
||||||
disabled={!editable}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-required={required ? 'true' : undefined}
|
|
||||||
aria-invalid={error ? 'true' : undefined}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
class="border-border/50 h-8 w-10 cursor-pointer rounded-lg border bg-transparent p-0.5 shadow-sm"
|
|
||||||
/>
|
|
||||||
<span class="text-muted-foreground font-mono text-xs">{value || '#3B82F6'}</span>
|
|
||||||
</div>
|
|
||||||
{:else if type === 'multiselect'}
|
|
||||||
<EditableMultiSelect
|
|
||||||
value={Array.isArray(value) ? value : []}
|
|
||||||
{options}
|
|
||||||
{placeholder}
|
|
||||||
emptyText={emptyText || 'None selected'}
|
|
||||||
disabled={!editable}
|
|
||||||
{loading}
|
|
||||||
error={loadingError}
|
|
||||||
{onRetry}
|
|
||||||
onchange={handleMultiSelectChange}
|
|
||||||
/>
|
|
||||||
{:else if type === 'readonly'}
|
|
||||||
<span class="text-foreground px-2.5 py-1.5 text-sm font-medium" aria-labelledby={labelId}>
|
|
||||||
{#if value}
|
|
||||||
{value}
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p
|
|
||||||
id={errorId}
|
|
||||||
class="text-destructive ml-32 px-2.5 pt-0.5 pb-1 text-[11px] font-medium"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { default as CrmDrawer } from './CrmDrawer.svelte';
|
|
||||||
export { default as CrmPropertyRow } from './CrmPropertyRow.svelte';
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
<script>
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { Check, ChevronDown, Building2, User } from '@lucide/svelte';
|
|
||||||
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import { getOptionStyle, getOptionLabel, getOptionBgColor } from '#lib/utils/table-helpers.js';
|
|
||||||
import { formatCurrency } from '#lib/utils/formatting.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} ColumnDef
|
|
||||||
* @property {string} key - Field key in data object
|
|
||||||
* @property {string} label - Display label for column header
|
|
||||||
* @property {'text' | 'email' | 'number' | 'date' | 'select' | 'checkbox' | 'relation'} [type='text']
|
|
||||||
* @property {string} [width] - Tailwind width class
|
|
||||||
* @property {{ value: string, label: string, color: string }[]} [options] - Options for select type
|
|
||||||
* @property {boolean} [editable=true] - Allow inline editing
|
|
||||||
* @property {boolean} [canHide=true] - Allow hiding column
|
|
||||||
* @property {string} [emptyText='Empty'] - Text when value is empty
|
|
||||||
* @property {(value: any, row?: any) => string} [format] - Custom value formatter (receives value and optional row data)
|
|
||||||
* @property {(row: any) => any} [getValue] - Custom value getter for nested data
|
|
||||||
* @property {string} [relationIcon] - Icon type for relation cells ('building' | 'user')
|
|
||||||
* @property {(row: any) => string} [cellClass] - Dynamic CSS class for cell text based on row data
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* data: any[],
|
|
||||||
* columns: ColumnDef[],
|
|
||||||
* visibleColumns?: string[],
|
|
||||||
* onRowChange?: (row: any, field: string, value: any) => void,
|
|
||||||
* onRowClick?: (row: any) => void,
|
|
||||||
* emptyState?: import('svelte').Snippet,
|
|
||||||
* cellContent?: import('svelte').Snippet<[any, ColumnDef]>,
|
|
||||||
* cellSuffix?: import('svelte').Snippet<[any, ColumnDef]>,
|
|
||||||
* selectable?: boolean,
|
|
||||||
* selectedIds?: string[],
|
|
||||||
* rowIdKey?: string,
|
|
||||||
* activeRowId?: string | null
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
data = [],
|
|
||||||
columns = [],
|
|
||||||
visibleColumns = $bindable(/** @type {string[]} */ ([])),
|
|
||||||
onRowChange,
|
|
||||||
onRowClick,
|
|
||||||
emptyState,
|
|
||||||
cellContent,
|
|
||||||
cellSuffix,
|
|
||||||
selectable = false,
|
|
||||||
selectedIds = $bindable(/** @type {string[]} */ ([])),
|
|
||||||
rowIdKey = 'id',
|
|
||||||
activeRowId = $bindable(/** @type {string | null} */ (null))
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Initialize visibleColumns from columns if not provided
|
|
||||||
$effect(() => {
|
|
||||||
if (visibleColumns.length === 0 && columns.length > 0) {
|
|
||||||
visibleColumns = columns.map((c) => c.key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Editing state
|
|
||||||
/** @type {{ rowId: string, columnKey: string } | null} */
|
|
||||||
let editingCell = $state(null);
|
|
||||||
let editValue = $state('');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get value from row, using custom getter if provided
|
|
||||||
* @param {any} row
|
|
||||||
* @param {ColumnDef} column
|
|
||||||
*/
|
|
||||||
function getCellValue(row, column) {
|
|
||||||
if (column.getValue) {
|
|
||||||
return column.getValue(row);
|
|
||||||
}
|
|
||||||
return row[column.key];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format value for display
|
|
||||||
* @param {any} value
|
|
||||||
* @param {ColumnDef} column
|
|
||||||
* @param {any} [row] - The full row data (for format functions that need row context)
|
|
||||||
*/
|
|
||||||
function formatValue(value, column, row) {
|
|
||||||
if (value == null || value === '') return null;
|
|
||||||
if (column.format) return column.format(value, row);
|
|
||||||
if (column.type === 'number') return formatCurrency(value);
|
|
||||||
if (column.type === 'date') return formatDate(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start editing a cell
|
|
||||||
* @param {string} rowId
|
|
||||||
* @param {string} columnKey
|
|
||||||
*/
|
|
||||||
async function startEditing(rowId, columnKey) {
|
|
||||||
const row = data.find((r) => r.id === rowId);
|
|
||||||
const column = columns.find((c) => c.key === columnKey);
|
|
||||||
|
|
||||||
if (!row || !column) return;
|
|
||||||
if (column.editable === false) return;
|
|
||||||
// Non-editable types
|
|
||||||
if (column.type === 'checkbox' || column.type === 'relation' || column.type === 'select')
|
|
||||||
return;
|
|
||||||
|
|
||||||
editingCell = { rowId, columnKey };
|
|
||||||
const value = getCellValue(row, column);
|
|
||||||
editValue = value?.toString() ?? '';
|
|
||||||
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const input = document.querySelector(`[data-edit-input="${rowId}-${columnKey}"]`);
|
|
||||||
if (input) {
|
|
||||||
// @ts-ignore
|
|
||||||
input.focus();
|
|
||||||
// @ts-ignore
|
|
||||||
if (input.select) input.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop editing and optionally save
|
|
||||||
* @param {boolean} save
|
|
||||||
*/
|
|
||||||
function stopEditing(save = true) {
|
|
||||||
if (!editingCell) return;
|
|
||||||
|
|
||||||
if (save && onRowChange) {
|
|
||||||
const { rowId, columnKey } = editingCell;
|
|
||||||
const column = columns.find((c) => c.key === columnKey);
|
|
||||||
const row = data.find((r) => r.id === rowId);
|
|
||||||
|
|
||||||
if (row && column) {
|
|
||||||
/** @type {string | number} */
|
|
||||||
let parsedValue = editValue;
|
|
||||||
if (column.type === 'number') {
|
|
||||||
parsedValue = parseFloat(editValue) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = getCellValue(row, column);
|
|
||||||
if (parsedValue !== original) {
|
|
||||||
onRowChange(row, columnKey, parsedValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
editingCell = null;
|
|
||||||
editValue = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {KeyboardEvent} e
|
|
||||||
*/
|
|
||||||
function handleKeydown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
stopEditing(true);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
stopEditing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update select value
|
|
||||||
* @param {string} rowId
|
|
||||||
* @param {string} columnKey
|
|
||||||
* @param {string} value
|
|
||||||
*/
|
|
||||||
function updateSelectValue(rowId, columnKey, value) {
|
|
||||||
const row = data.find((r) => r.id === rowId);
|
|
||||||
if (row && onRowChange) {
|
|
||||||
onRowChange(row, columnKey, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle checkbox value
|
|
||||||
* @param {string} rowId
|
|
||||||
* @param {string} columnKey
|
|
||||||
*/
|
|
||||||
function toggleCheckbox(rowId, columnKey) {
|
|
||||||
const row = data.find((r) => r.id === rowId);
|
|
||||||
const column = columns.find((c) => c.key === columnKey);
|
|
||||||
if (row && column && onRowChange) {
|
|
||||||
const currentValue = getCellValue(row, column);
|
|
||||||
onRowChange(row, columnKey, !currentValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived values
|
|
||||||
const visibleColumnDefs = $derived(columns.filter((col) => visibleColumns.includes(col.key)));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet tableBody()}
|
|
||||||
{#if data.length === 0}
|
|
||||||
{#if emptyState}
|
|
||||||
{@render emptyState()}
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<p class="text-muted-foreground text-sm">No items found</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<table class="w-full border-collapse">
|
|
||||||
<thead class="sticky top-0 z-[1] bg-[color:var(--bg)]">
|
|
||||||
<tr class="border-b border-[color:var(--border-faint)]">
|
|
||||||
{#if selectable}
|
|
||||||
<th class="w-10 px-3 py-2.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="Select all rows"
|
|
||||||
checked={data.length > 0 && selectedIds.length === data.length}
|
|
||||||
indeterminate={selectedIds.length > 0 && selectedIds.length < data.length}
|
|
||||||
onchange={(e) => {
|
|
||||||
const checked = /** @type {HTMLInputElement} */ (e.currentTarget).checked;
|
|
||||||
selectedIds = checked ? data.map((r) => r[rowIdKey]) : [];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
{/if}
|
|
||||||
{#each visibleColumnDefs as column, colIndex (column.key)}
|
|
||||||
<th
|
|
||||||
class="px-[14px] py-[9px] text-left text-[10px] font-medium tracking-[0.06em] text-[color:var(--text-subtle)] uppercase {colIndex ===
|
|
||||||
0
|
|
||||||
? 'pl-7 md:pl-8'
|
|
||||||
: ''} {column.width || ''}"
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data as row (row.id)}
|
|
||||||
<tr
|
|
||||||
class="border-b border-[color:var(--border-faint)] transition-colors {onRowClick
|
|
||||||
? 'cursor-pointer'
|
|
||||||
: ''} {activeRowId === row[rowIdKey]
|
|
||||||
? 'bg-[color:var(--bg-active)]'
|
|
||||||
: onRowClick
|
|
||||||
? 'hover:bg-[color:var(--bg-hover)]'
|
|
||||||
: ''}"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (!onRowClick) return;
|
|
||||||
const target = /** @type {HTMLElement} */ (e.target);
|
|
||||||
if (
|
|
||||||
target.closest('button') ||
|
|
||||||
target.closest('input') ||
|
|
||||||
target.closest('[data-dropdown-trigger]') ||
|
|
||||||
target.closest('[role="menu"]')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRowId = row[rowIdKey];
|
|
||||||
onRowClick(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if selectable}
|
|
||||||
<td class="w-10 px-3 py-3" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="Select row"
|
|
||||||
checked={selectedIds.includes(row[rowIdKey])}
|
|
||||||
onchange={(e) => {
|
|
||||||
const id = row[rowIdKey];
|
|
||||||
const checked = /** @type {HTMLInputElement} */ (e.currentTarget).checked;
|
|
||||||
selectedIds = checked
|
|
||||||
? [...selectedIds, id]
|
|
||||||
: selectedIds.filter((x) => x !== id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
{#each visibleColumnDefs as column, colIndex (column.key)}
|
|
||||||
{@const value = getCellValue(row, column)}
|
|
||||||
{@const formattedValue = formatValue(value, column, row)}
|
|
||||||
<td
|
|
||||||
class="h-[37px] px-[14px] text-[12px] {colIndex === 0
|
|
||||||
? 'pl-7 font-medium text-[color:var(--text)] md:pl-8'
|
|
||||||
: 'text-[color:var(--text-muted)]'} {colIndex === 0 &&
|
|
||||||
activeRowId === row[rowIdKey]
|
|
||||||
? 'shadow-[inset_2.5px_0_0_var(--text)]'
|
|
||||||
: ''} {column.width || ''}"
|
|
||||||
>
|
|
||||||
{#if cellContent}
|
|
||||||
{@render cellContent(row, column)}
|
|
||||||
{:else if column.type === 'text' || column.type === 'email' || column.type === 'number' || !column.type}
|
|
||||||
{#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange}
|
|
||||||
<input
|
|
||||||
type={column.type === 'email'
|
|
||||||
? 'email'
|
|
||||||
: column.type === 'number'
|
|
||||||
? 'number'
|
|
||||||
: 'text'}
|
|
||||||
bind:value={editValue}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
onblur={() => stopEditing(true)}
|
|
||||||
data-edit-input="{row.id}-{column.key}"
|
|
||||||
class="w-full rounded bg-white px-2 py-1.5 text-sm shadow-sm ring-1 ring-gray-200 transition-shadow duration-100 outline-none focus:ring-blue-300 dark:bg-gray-900 dark:ring-gray-700"
|
|
||||||
/>
|
|
||||||
{:else if onRowChange && column.editable !== false}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => startEditing(row.id, column.key)}
|
|
||||||
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
|
|
||||||
>
|
|
||||||
{#if formattedValue}
|
|
||||||
{formattedValue}
|
|
||||||
{:else}
|
|
||||||
<span class="text-[color:var(--text-subtle)]"
|
|
||||||
>{column.emptyText || 'Empty'}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm {column.cellClass ? column.cellClass(row) : ''}">
|
|
||||||
{#if formattedValue}
|
|
||||||
{formattedValue}
|
|
||||||
{:else}
|
|
||||||
<span class="text-[color:var(--text-subtle)]"
|
|
||||||
>{column.emptyText || 'Empty'}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if column.type === 'date'}
|
|
||||||
{#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange}
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
bind:value={editValue}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
onblur={() => stopEditing(true)}
|
|
||||||
data-edit-input="{row.id}-{column.key}"
|
|
||||||
class="w-full rounded bg-white px-2 py-1.5 text-sm shadow-sm ring-1 ring-gray-200 transition-shadow duration-100 outline-none focus:ring-blue-300 dark:bg-gray-900 dark:ring-gray-700"
|
|
||||||
/>
|
|
||||||
{:else if onRowChange && column.editable !== false}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => startEditing(row.id, column.key)}
|
|
||||||
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
|
|
||||||
>
|
|
||||||
{#if formattedValue}
|
|
||||||
{formattedValue}
|
|
||||||
{:else}
|
|
||||||
<span class="text-[color:var(--text-subtle)]"
|
|
||||||
>{column.emptyText || 'Empty'}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm {column.cellClass ? column.cellClass(row) : ''}">
|
|
||||||
{#if formattedValue}
|
|
||||||
{formattedValue}
|
|
||||||
{:else}
|
|
||||||
<span class="text-[color:var(--text-subtle)]"
|
|
||||||
>{column.emptyText || 'Empty'}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if column.type === 'select' && column.options}
|
|
||||||
{#if onRowChange}
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80 {getOptionStyle(
|
|
||||||
value,
|
|
||||||
column.options
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{getOptionLabel(value, column.options)}
|
|
||||||
<ChevronDown class="h-3 w-3 opacity-60" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="start" class="w-36">
|
|
||||||
{#each column.options as option (option.value)}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onclick={() => updateSelectValue(row.id, column.key, option.value)}
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="h-2 w-2 rounded-full {getOptionBgColor(option.value, [
|
|
||||||
option
|
|
||||||
])}"
|
|
||||||
></span>
|
|
||||||
{option.label}
|
|
||||||
{#if value === option.value}
|
|
||||||
<Check class="ml-auto h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{/each}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {getOptionStyle(
|
|
||||||
value,
|
|
||||||
column.options
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{getOptionLabel(value, column.options)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if column.type === 'checkbox'}
|
|
||||||
{#if onRowChange}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => toggleCheckbox(row.id, column.key)}
|
|
||||||
class="flex h-5 w-5 items-center justify-center rounded border transition-colors {value
|
|
||||||
? 'border-blue-500 bg-blue-500'
|
|
||||||
: 'border-gray-300 hover:border-gray-400'}"
|
|
||||||
>
|
|
||||||
{#if value}
|
|
||||||
<Check class="h-3.5 w-3.5 text-white" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="flex h-5 w-5 items-center justify-center rounded border {value
|
|
||||||
? 'border-blue-500 bg-blue-500'
|
|
||||||
: 'border-gray-300'}"
|
|
||||||
>
|
|
||||||
{#if value}
|
|
||||||
<Check class="h-3.5 w-3.5 text-white" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if column.type === 'relation'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
activeRowId = row[rowIdKey];
|
|
||||||
onRowClick?.(row);
|
|
||||||
}}
|
|
||||||
class="-mx-2 -my-1.5 w-full cursor-pointer rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
|
|
||||||
>
|
|
||||||
{#if value}
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
{#if column.relationIcon === 'user'}
|
|
||||||
<User class="h-3.5 w-3.5 text-[color:var(--text-subtle)]" />
|
|
||||||
{:else}
|
|
||||||
<Building2 class="h-3.5 w-3.5 text-[color:var(--text-subtle)]" />
|
|
||||||
{/if}
|
|
||||||
<span class="truncate"
|
|
||||||
>{typeof value === 'object' ? value.name || value.email : value}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="text-[color:var(--text-subtle)]">-</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if cellSuffix}
|
|
||||||
{@render cellSuffix(row, column)}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{@render tableBody()}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as CrmTable } from './CrmTable.svelte';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<script>
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), value = $bindable([]), ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.CheckboxGroup
|
|
||||||
bind:ref
|
|
||||||
bind:value
|
|
||||||
data-slot="dropdown-menu-checkbox-group"
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script>
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
checked = $bindable(false),
|
|
||||||
indeterminate = $bindable(false),
|
|
||||||
class: className,
|
|
||||||
children: childrenProp,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
bind:ref
|
|
||||||
bind:checked
|
|
||||||
bind:indeterminate
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
class={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{#snippet children({ checked, indeterminate })}
|
|
||||||
<span class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center">
|
|
||||||
{#if indeterminate}
|
|
||||||
<MinusIcon class="size-4" />
|
|
||||||
{:else}
|
|
||||||
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{@render childrenProp?.()}
|
|
||||||
{/snippet}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* sideOffset?: number,
|
|
||||||
* portalProps?: Record<string, any>,
|
|
||||||
* class?: string,
|
|
||||||
* align?: 'start' | 'center' | 'end',
|
|
||||||
* side?: 'top' | 'right' | 'bottom' | 'left',
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
sideOffset = 4,
|
|
||||||
portalProps = {},
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
bind:ref
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
{sideOffset}
|
|
||||||
class={cn(
|
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md outline-none',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<script>
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* inset?: boolean,
|
|
||||||
* variant?: 'default' | 'destructive',
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
inset = false,
|
|
||||||
variant = 'default',
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
bind:ref
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
class={cn(
|
|
||||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* inset?: boolean,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, inset = false, children, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8', className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script>
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLElement | null,
|
|
||||||
* class?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
bind:ref
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
class={cn('bg-border -mx-1 my-1 h-px', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<script>
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
|
||||||
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
|
|
||||||
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
|
|
||||||
import Content from './dropdown-menu-content.svelte';
|
|
||||||
import Group from './dropdown-menu-group.svelte';
|
|
||||||
import Item from './dropdown-menu-item.svelte';
|
|
||||||
import Label from './dropdown-menu-label.svelte';
|
|
||||||
import Separator from './dropdown-menu-separator.svelte';
|
|
||||||
import Trigger from './dropdown-menu-trigger.svelte';
|
|
||||||
const Sub = DropdownMenuPrimitive.Sub;
|
|
||||||
const Root = DropdownMenuPrimitive.Root;
|
|
||||||
|
|
||||||
export {
|
|
||||||
CheckboxGroup,
|
|
||||||
CheckboxItem,
|
|
||||||
Content,
|
|
||||||
Root as DropdownMenu,
|
|
||||||
CheckboxGroup as DropdownMenuCheckboxGroup,
|
|
||||||
CheckboxItem as DropdownMenuCheckboxItem,
|
|
||||||
Content as DropdownMenuContent,
|
|
||||||
Group as DropdownMenuGroup,
|
|
||||||
Item as DropdownMenuItem,
|
|
||||||
Label as DropdownMenuLabel,
|
|
||||||
Separator as DropdownMenuSeparator,
|
|
||||||
Sub as DropdownMenuSub,
|
|
||||||
Trigger as DropdownMenuTrigger,
|
|
||||||
Group,
|
|
||||||
Item,
|
|
||||||
Label,
|
|
||||||
Root,
|
|
||||||
Separator,
|
|
||||||
Sub,
|
|
||||||
Trigger
|
|
||||||
};
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
<script>
|
|
||||||
import { ChevronDown, X, Search, AlertCircle, RotateCw } from '@lucide/svelte';
|
|
||||||
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
|
||||||
import { Skeleton } from '#lib/components/ui/skeleton/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} SelectOption
|
|
||||||
* @property {string} [id]
|
|
||||||
* @property {string} [value]
|
|
||||||
* @property {string} [name]
|
|
||||||
* @property {string} [label]
|
|
||||||
* @property {string} [email]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* value?: string[],
|
|
||||||
* options?: SelectOption[],
|
|
||||||
* placeholder?: string,
|
|
||||||
* emptyText?: string,
|
|
||||||
* disabled?: boolean,
|
|
||||||
* loading?: boolean,
|
|
||||||
* error?: string,
|
|
||||||
* onRetry?: () => void,
|
|
||||||
* onchange?: (value: string[]) => void,
|
|
||||||
* class?: string,
|
|
||||||
* maxDisplay?: number,
|
|
||||||
* searchThreshold?: number,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
value = [],
|
|
||||||
options = [],
|
|
||||||
placeholder = 'Select...',
|
|
||||||
emptyText = 'None selected',
|
|
||||||
disabled = false,
|
|
||||||
loading = false,
|
|
||||||
error = '',
|
|
||||||
onRetry,
|
|
||||||
onchange,
|
|
||||||
class: className,
|
|
||||||
maxDisplay = 3,
|
|
||||||
searchThreshold = 6
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
let query = $state('');
|
|
||||||
|
|
||||||
// Reset the search query whenever the dropdown closes
|
|
||||||
$effect(() => {
|
|
||||||
if (!isOpen) query = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle an option
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
function toggleOption(id) {
|
|
||||||
const newValue = value.includes(id) ? value.filter((v) => v !== id) : [...value, id];
|
|
||||||
onchange?.(newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an option
|
|
||||||
* @param {Event} e
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
function removeOption(e, id) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newValue = value.filter((v) => v !== id);
|
|
||||||
onchange?.(newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize option data so shared drawers can pass either `{ id, name }`
|
|
||||||
* or `{ value, label }` without crashing keyed each blocks.
|
|
||||||
* @returns {Array<SelectOption & { id: string, name: string }>}
|
|
||||||
*/
|
|
||||||
const normalizedOptions = $derived(
|
|
||||||
options
|
|
||||||
.map((opt) => {
|
|
||||||
const id = opt?.id ?? opt?.value;
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...opt,
|
|
||||||
id,
|
|
||||||
name: opt?.name ?? opt?.label ?? opt?.email ?? id
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show the search input once there are enough options to make it useful
|
|
||||||
const showSearch = $derived(!loading && !error && normalizedOptions.length >= searchThreshold);
|
|
||||||
|
|
||||||
const filteredOptions = $derived.by(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return normalizedOptions;
|
|
||||||
return normalizedOptions.filter((opt) => {
|
|
||||||
const name = (opt.name || '').toLowerCase();
|
|
||||||
const email = (opt.email || '').toLowerCase();
|
|
||||||
return name.includes(q) || email.includes(q);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get selected options
|
|
||||||
*/
|
|
||||||
const selectedOptions = $derived(normalizedOptions.filter((opt) => value.includes(opt.id)));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display options (limited to maxDisplay)
|
|
||||||
*/
|
|
||||||
const displayOptions = $derived(selectedOptions.slice(0, maxDisplay));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get remaining count
|
|
||||||
*/
|
|
||||||
const remainingCount = $derived(
|
|
||||||
selectedOptions.length > maxDisplay ? selectedOptions.length - maxDisplay : 0
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class={cn('group', className)}>
|
|
||||||
<DropdownMenu.Root bind:open={isOpen}>
|
|
||||||
<DropdownMenu.Trigger {disabled} class="w-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'flex min-h-[36px] w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
|
||||||
!disabled && 'hover:bg-muted/50 cursor-pointer',
|
|
||||||
disabled && 'cursor-default opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 flex-wrap items-center gap-1.5">
|
|
||||||
{#if selectedOptions.length === 0}
|
|
||||||
<span class="text-muted-foreground italic">{emptyText || placeholder}</span>
|
|
||||||
{:else}
|
|
||||||
{#each displayOptions as opt (opt.id)}
|
|
||||||
<Badge variant="secondary" class="gap-1 pr-1">
|
|
||||||
<span class="max-w-[120px] truncate">{opt.name}</span>
|
|
||||||
{#if !disabled}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Remove {opt.name}"
|
|
||||||
class="hover:bg-muted rounded-sm p-0.5"
|
|
||||||
onclick={(e) => removeOption(e, opt.id)}
|
|
||||||
>
|
|
||||||
<X class="h-3 w-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</Badge>
|
|
||||||
{/each}
|
|
||||||
{#if remainingCount > 0}
|
|
||||||
<Badge variant="outline" class="text-xs">+{remainingCount}</Badge>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
aria-hidden="true"
|
|
||||||
class={cn(
|
|
||||||
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
|
|
||||||
isOpen && 'rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content class="max-h-72 w-64 overflow-hidden p-0" align="start">
|
|
||||||
{#if showSearch}
|
|
||||||
<div class="border-border/40 bg-background sticky top-0 z-10 border-b p-1.5">
|
|
||||||
<div class="relative">
|
|
||||||
<Search
|
|
||||||
aria-hidden="true"
|
|
||||||
class="text-muted-foreground/60 pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Search…"
|
|
||||||
aria-label="Search options"
|
|
||||||
class="focus:ring-primary/30 placeholder:text-muted-foreground/50 w-full rounded-md border-0 bg-transparent py-1.5 pr-2 pl-7 text-xs outline-none focus:ring-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="max-h-56 overflow-y-auto p-1">
|
|
||||||
{#if loading}
|
|
||||||
<div class="space-y-1.5 px-1 py-1.5" aria-busy="true" aria-live="polite">
|
|
||||||
{#each { length: 3 } as _, i (i)}
|
|
||||||
<div class="flex items-center gap-2 px-1.5 py-1">
|
|
||||||
<Skeleton class="h-3.5 w-3.5 rounded" />
|
|
||||||
<Skeleton class="h-3.5 flex-1 rounded" />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-start gap-2 px-2 py-3 text-xs" role="alert">
|
|
||||||
<AlertCircle class="text-destructive mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
||||||
<div class="flex-1 space-y-1.5">
|
|
||||||
<p class="text-destructive font-medium">{error}</p>
|
|
||||||
{#if onRetry}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onRetry}
|
|
||||||
class="text-primary hover:bg-primary/10 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium"
|
|
||||||
>
|
|
||||||
<RotateCw class="h-3 w-3" aria-hidden="true" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if normalizedOptions.length === 0}
|
|
||||||
<div class="text-muted-foreground px-2 py-4 text-center text-sm">
|
|
||||||
No options available
|
|
||||||
</div>
|
|
||||||
{:else if filteredOptions.length === 0}
|
|
||||||
<div class="text-muted-foreground px-2 py-4 text-center text-xs">
|
|
||||||
No matches for "{query}"
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<DropdownMenu.CheckboxGroup {value}>
|
|
||||||
{#each filteredOptions as opt (opt.id)}
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
checked={value.includes(opt.id)}
|
|
||||||
onCheckedChange={() => toggleOption(opt.id)}
|
|
||||||
closeOnSelect={false}
|
|
||||||
class=""
|
|
||||||
>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>{opt.name}</span>
|
|
||||||
{#if opt.email && opt.email !== opt.name}
|
|
||||||
<span class="text-muted-foreground text-xs">{opt.email}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
{/each}
|
|
||||||
</DropdownMenu.CheckboxGroup>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as EditableMultiSelect } from './EditableMultiSelect.svelte';
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
<script>
|
|
||||||
import { CalendarDays, ChevronDown, X, Clock, ArrowRight } from '@lucide/svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
import Calendar from '#lib/components/ui/calendar/Calendar.svelte';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
import { today, getLocalTimeZone, parseDate } from '@internationalized/date';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* startDate?: string,
|
|
||||||
* endDate?: string,
|
|
||||||
* label?: string,
|
|
||||||
* placeholder?: string,
|
|
||||||
* class?: string,
|
|
||||||
* onchange?: (startDate: string, endDate: string) => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
startDate = $bindable(''),
|
|
||||||
endDate = $bindable(''),
|
|
||||||
label: _label = '',
|
|
||||||
placeholder = 'Select dates',
|
|
||||||
class: className,
|
|
||||||
onchange
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let selectingEnd = $state(false);
|
|
||||||
|
|
||||||
const todayDate = today(getLocalTimeZone());
|
|
||||||
|
|
||||||
// Convert string dates to DateValue for calendar
|
|
||||||
const startDateValue = $derived(startDate ? parseDate(startDate) : undefined);
|
|
||||||
const endDateValue = $derived(endDate ? parseDate(endDate) : undefined);
|
|
||||||
|
|
||||||
/** @type {{ label: string, shortLabel: string, getRange: () => { start: string, end: string } }[]} */
|
|
||||||
const presets = [
|
|
||||||
{
|
|
||||||
label: 'Today',
|
|
||||||
shortLabel: 'Today',
|
|
||||||
getRange: () => {
|
|
||||||
const d = todayDate.toString();
|
|
||||||
return { start: d, end: d };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last 7 days',
|
|
||||||
shortLabel: '7d',
|
|
||||||
getRange: () => ({
|
|
||||||
start: todayDate.subtract({ days: 7 }).toString(),
|
|
||||||
end: todayDate.toString()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last 30 days',
|
|
||||||
shortLabel: '30d',
|
|
||||||
getRange: () => ({
|
|
||||||
start: todayDate.subtract({ days: 30 }).toString(),
|
|
||||||
end: todayDate.toString()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'This month',
|
|
||||||
shortLabel: 'Month',
|
|
||||||
getRange: () => ({
|
|
||||||
start: todayDate.set({ day: 1 }).toString(),
|
|
||||||
end: todayDate.toString()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last month',
|
|
||||||
shortLabel: 'Prev',
|
|
||||||
getRange: () => {
|
|
||||||
const lastMonth = todayDate.subtract({ months: 1 });
|
|
||||||
const lastDayOfLastMonth = lastMonth
|
|
||||||
.set({ day: 1 })
|
|
||||||
.add({ months: 1 })
|
|
||||||
.subtract({ days: 1 });
|
|
||||||
return {
|
|
||||||
start: lastMonth.set({ day: 1 }).toString(),
|
|
||||||
end: lastDayOfLastMonth.toString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last 90 days',
|
|
||||||
shortLabel: '90d',
|
|
||||||
getRange: () => ({
|
|
||||||
start: todayDate.subtract({ days: 90 }).toString(),
|
|
||||||
end: todayDate.toString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const displayText = $derived.by(() => {
|
|
||||||
if (!startDate && !endDate) return placeholder;
|
|
||||||
if (startDate && endDate) {
|
|
||||||
if (startDate === endDate) return formatDate(startDate);
|
|
||||||
return `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`;
|
|
||||||
}
|
|
||||||
if (startDate) return `From ${formatDateShort(startDate)}`;
|
|
||||||
return `Until ${formatDateShort(endDate)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} dateStr
|
|
||||||
*/
|
|
||||||
function formatDateShort(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{ label: string, shortLabel: string, getRange: () => { start: string, end: string } }} preset
|
|
||||||
*/
|
|
||||||
function handlePreset(preset) {
|
|
||||||
const { start, end } = preset.getRange();
|
|
||||||
startDate = start;
|
|
||||||
endDate = end;
|
|
||||||
onchange?.(start, end);
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import('@internationalized/date').DateValue | undefined} dateValue
|
|
||||||
*/
|
|
||||||
function handleDateSelect(dateValue) {
|
|
||||||
if (!dateValue) return;
|
|
||||||
const dateStr = dateValue.toString();
|
|
||||||
|
|
||||||
if (!selectingEnd) {
|
|
||||||
startDate = dateStr;
|
|
||||||
endDate = '';
|
|
||||||
selectingEnd = true;
|
|
||||||
} else {
|
|
||||||
// Ensure end is after start
|
|
||||||
if (dateStr < startDate) {
|
|
||||||
endDate = startDate;
|
|
||||||
startDate = dateStr;
|
|
||||||
} else {
|
|
||||||
endDate = dateStr;
|
|
||||||
}
|
|
||||||
selectingEnd = false;
|
|
||||||
onchange?.(startDate, endDate);
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
startDate = '';
|
|
||||||
endDate = '';
|
|
||||||
selectingEnd = false;
|
|
||||||
onchange?.('', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasValue = $derived(!!startDate || !!endDate);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root
|
|
||||||
bind:open
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
if (!o) selectingEnd = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Trigger asChild class="">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
class={cn(
|
|
||||||
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[13px] leading-none font-medium transition-colors focus:ring-1 focus:ring-[color:var(--ring)] focus:outline-none',
|
|
||||||
hasValue
|
|
||||||
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
|
|
||||||
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CalendarDays class="size-3.5 shrink-0" />
|
|
||||||
<span class="truncate">{displayText}</span>
|
|
||||||
{#if hasValue}
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
handleClear();
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
handleClear();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
|
|
||||||
aria-label="Clear filter"
|
|
||||||
>
|
|
||||||
<X class="size-3" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Content
|
|
||||||
align="start"
|
|
||||||
sideOffset={4}
|
|
||||||
class="w-auto overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-0 shadow-lg shadow-black/5"
|
|
||||||
>
|
|
||||||
<div class="flex">
|
|
||||||
<!-- Presets sidebar -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'border-border/40 flex flex-col gap-0.5 border-r p-2',
|
|
||||||
'bg-muted/30 dark:border-white/[0.04] dark:bg-white/[0.02]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div class="mb-1 px-2 py-1">
|
|
||||||
<span class="text-muted-foreground/60 text-[10px] font-semibold tracking-wider uppercase">
|
|
||||||
Quick select
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#each presets as preset, i (preset.label)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class={cn(
|
|
||||||
'h-8 justify-start px-3 text-xs font-medium',
|
|
||||||
'text-muted-foreground hover:text-foreground',
|
|
||||||
'hover:bg-primary/10 dark:hover:bg-primary/20',
|
|
||||||
'transition-all duration-150',
|
|
||||||
'animate-in fade-in slide-in-from-left-1'
|
|
||||||
)}
|
|
||||||
style="animation-delay: {i * 30}ms"
|
|
||||||
onclick={() => handlePreset(preset)}
|
|
||||||
>
|
|
||||||
<Clock class="mr-2 h-3 w-3 opacity-50" />
|
|
||||||
{preset.label}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar section -->
|
|
||||||
<div class="p-3">
|
|
||||||
<!-- Date selection status -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'mb-3 flex items-center justify-center gap-2',
|
|
||||||
'bg-muted/50 rounded-lg px-3 py-2',
|
|
||||||
'dark:bg-white/[0.03]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if startDate}
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<div class="bg-primary h-2 w-2 rounded-full"></div>
|
|
||||||
<span class="text-foreground text-xs font-medium">
|
|
||||||
{formatDateShort(startDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground text-xs">Start date</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ArrowRight class="text-muted-foreground/50 h-3 w-3" />
|
|
||||||
|
|
||||||
{#if endDate}
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<div class="bg-primary h-2 w-2 rounded-full"></div>
|
|
||||||
<span class="text-foreground text-xs font-medium">
|
|
||||||
{formatDateShort(endDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground text-xs">
|
|
||||||
{selectingEnd ? 'Select end' : 'End date'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selection hint -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'mb-2 text-center text-xs font-medium',
|
|
||||||
'transition-colors duration-200',
|
|
||||||
selectingEnd ? 'text-primary' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if selectingEnd}
|
|
||||||
<span class="animate-pulse">Click to select end date</span>
|
|
||||||
{:else}
|
|
||||||
Select start date
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar -->
|
|
||||||
<div class="date-calendar-wrapper">
|
|
||||||
<Calendar
|
|
||||||
value={selectingEnd ? endDateValue : startDateValue}
|
|
||||||
onValueChange={handleDateSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Filter, X, ChevronDown, Sparkles } from '@lucide/svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { Button } from '#lib/components/ui/button/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* activeCount?: number,
|
|
||||||
* collapsible?: boolean,
|
|
||||||
* defaultExpanded?: boolean,
|
|
||||||
* expanded?: boolean,
|
|
||||||
* minimal?: boolean,
|
|
||||||
* class?: string,
|
|
||||||
* onClear?: () => void,
|
|
||||||
* children?: import('svelte').Snippet,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
activeCount = 0,
|
|
||||||
collapsible = false,
|
|
||||||
defaultExpanded = true,
|
|
||||||
expanded: externalExpanded,
|
|
||||||
minimal = false,
|
|
||||||
class: className,
|
|
||||||
onClear,
|
|
||||||
children
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Use a function to capture initial value
|
|
||||||
let internalExpanded = $state((() => defaultExpanded)());
|
|
||||||
|
|
||||||
// Use external expanded if provided, otherwise use internal state
|
|
||||||
const isExpanded = $derived(externalExpanded !== undefined ? externalExpanded : internalExpanded);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if minimal}
|
|
||||||
<!-- Minimal mode: sleek inline filter row with glass effect -->
|
|
||||||
{#if isExpanded}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'filter-bar-minimal',
|
|
||||||
'relative flex flex-wrap items-end gap-3 px-4 py-3',
|
|
||||||
'from-muted/40 via-muted/20 to-muted/40 bg-gradient-to-r',
|
|
||||||
'border-border/50 border backdrop-blur-sm',
|
|
||||||
'dark:from-white/[0.02] dark:via-white/[0.04] dark:to-white/[0.02]',
|
|
||||||
'dark:border-white/[0.06]',
|
|
||||||
'transition-all duration-300 ease-out',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Subtle gradient overlay -->
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100 dark:opacity-[0.02]"
|
|
||||||
style="background: radial-gradient(ellipse at 50% 0%, var(--accent-primary) 0%, transparent 70%);"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Filter content -->
|
|
||||||
<div class="relative z-10 flex flex-wrap items-end gap-3">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Clear button with animation -->
|
|
||||||
{#if activeCount > 0 && onClear}
|
|
||||||
<div class="relative z-10 ml-auto flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="bg-primary/10 dark:bg-primary/20 flex items-center gap-1.5 rounded-full px-2.5 py-1"
|
|
||||||
>
|
|
||||||
<Sparkles class="text-primary h-3 w-3" />
|
|
||||||
<span class="text-primary text-xs font-semibold">
|
|
||||||
{activeCount} active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={onClear}
|
|
||||||
class="group/clear text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 h-8 gap-1.5 rounded-lg px-3 text-xs font-medium transition-all"
|
|
||||||
>
|
|
||||||
<X class="h-3.5 w-3.5 transition-transform group-hover/clear:rotate-90" />
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<!-- Full mode: Refined card with premium styling -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'filter-bar-full',
|
|
||||||
'overflow-hidden rounded-xl',
|
|
||||||
'bg-card/80 backdrop-blur-md',
|
|
||||||
'border-border/60 border',
|
|
||||||
'shadow-sm',
|
|
||||||
'dark:bg-card/60 dark:border-white/[0.08]',
|
|
||||||
'dark:shadow-[0_4px_24px_-4px_rgba(0,0,0,0.3)]',
|
|
||||||
'transition-all duration-300',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex items-center justify-between px-4 py-2.5',
|
|
||||||
'from-muted/30 bg-gradient-to-r to-transparent',
|
|
||||||
'dark:from-white/[0.02] dark:to-transparent'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'group flex items-center gap-2.5 text-sm font-semibold',
|
|
||||||
'text-foreground/80 hover:text-foreground transition-colors',
|
|
||||||
!collapsible && 'cursor-default'
|
|
||||||
)}
|
|
||||||
onclick={() => collapsible && (internalExpanded = !internalExpanded)}
|
|
||||||
disabled={!collapsible}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex h-7 w-7 items-center justify-center rounded-lg',
|
|
||||||
'bg-primary/10 text-primary',
|
|
||||||
'dark:bg-primary/20',
|
|
||||||
'transition-all duration-200',
|
|
||||||
collapsible && 'group-hover:bg-primary/20 group-hover:scale-105'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Filter class="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
<span class="tracking-tight">Filters</span>
|
|
||||||
{#if activeCount > 0}
|
|
||||||
<span
|
|
||||||
class={cn(
|
|
||||||
'inline-flex items-center justify-center',
|
|
||||||
'h-5 min-w-5 rounded-full px-1.5',
|
|
||||||
'bg-primary text-primary-foreground text-[10px] font-bold',
|
|
||||||
'animate-in fade-in zoom-in duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeCount}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if collapsible}
|
|
||||||
<div class="ml-1 transition-transform duration-200" class:rotate-180={isExpanded}>
|
|
||||||
<ChevronDown class="h-4 w-4 opacity-50" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if activeCount > 0 && onClear}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={onClear}
|
|
||||||
class="group/clear text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-7 gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-all"
|
|
||||||
>
|
|
||||||
<X class="h-3 w-3 transition-transform group-hover/clear:rotate-90" />
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter content with smooth expand/collapse -->
|
|
||||||
{#if !collapsible || isExpanded}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'border-border/40 border-t px-4 py-4',
|
|
||||||
'dark:border-white/[0.04]',
|
|
||||||
'animate-in fade-in slide-in-from-top-2 duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.filter-bar-minimal {
|
|
||||||
--filter-glow: 0 0 0 1px var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar-minimal:focus-within {
|
|
||||||
--filter-glow: 0 0 0 1px var(--ring), 0 0 20px -8px var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .filter-bar-minimal:focus-within {
|
|
||||||
--filter-glow: 0 0 0 1px var(--ring), 0 0 30px -10px var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar-minimal {
|
|
||||||
box-shadow: var(--filter-glow);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Search, X } from '@lucide/svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* value?: string,
|
|
||||||
* placeholder?: string,
|
|
||||||
* class?: string,
|
|
||||||
* onchange?: (value: string) => void,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
value = $bindable(''),
|
|
||||||
placeholder = 'Search...',
|
|
||||||
class: className,
|
|
||||||
onchange,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
/** @type {HTMLInputElement | undefined} */
|
|
||||||
let inputEl = $state();
|
|
||||||
let inputValue = $state(value);
|
|
||||||
let isFocused = $state(false);
|
|
||||||
|
|
||||||
// Sync external value changes to internal state only when not focused
|
|
||||||
$effect(() => {
|
|
||||||
if (!isFocused && value !== inputValue) {
|
|
||||||
inputValue = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
function handleInput(e) {
|
|
||||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
|
||||||
inputValue = target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
isFocused = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocused = false;
|
|
||||||
if (inputValue !== value) {
|
|
||||||
value = inputValue;
|
|
||||||
onchange?.(inputValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {KeyboardEvent} e
|
|
||||||
*/
|
|
||||||
function handleKeydown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
inputEl?.blur();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
handleClear();
|
|
||||||
inputEl?.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
inputValue = '';
|
|
||||||
value = '';
|
|
||||||
onchange?.('');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative inline-flex items-center {className || ''}" {...restProps}>
|
|
||||||
<Search class="pointer-events-none absolute left-2.5 size-3.5 text-[color:var(--text-subtle)]" />
|
|
||||||
<input
|
|
||||||
id="search-input"
|
|
||||||
bind:this={inputEl}
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
oninput={handleInput}
|
|
||||||
onfocus={handleFocus}
|
|
||||||
onblur={handleBlur}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
{placeholder}
|
|
||||||
class="h-7 w-full rounded-[var(--r-sm)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] py-0 pr-7 pl-8 text-[13px] text-[color:var(--text)] transition-colors outline-none placeholder:text-[color:var(--text-subtle)] focus:border-[color:var(--violet)]/50 focus:ring-1 focus:ring-[color:var(--violet)]/30"
|
|
||||||
/>
|
|
||||||
{#if inputValue}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleClear}
|
|
||||||
class="absolute right-1.5 flex size-4 items-center justify-center rounded-sm text-[color:var(--text-subtle)] hover:bg-[color:var(--bg-hover)] hover:text-[color:var(--text-muted)]"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X class="size-3" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
<script>
|
|
||||||
import { ChevronDown, Check, X } from '@lucide/svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* icon?: any,
|
|
||||||
* }} Option
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* options: Option[],
|
|
||||||
* value?: string | string[],
|
|
||||||
* multiple?: boolean,
|
|
||||||
* placeholder?: string,
|
|
||||||
* label?: string,
|
|
||||||
* allLabel?: string,
|
|
||||||
* class?: string,
|
|
||||||
* onchange?: (value: string | string[]) => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
options = [],
|
|
||||||
value = $bindable(''),
|
|
||||||
multiple = false,
|
|
||||||
placeholder = 'Select...',
|
|
||||||
label: _label = '',
|
|
||||||
allLabel = 'All',
|
|
||||||
class: className,
|
|
||||||
onchange
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
const selectedValues = $derived(
|
|
||||||
multiple ? (Array.isArray(value) ? value : value ? [value] : []) : []
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayText = $derived.by(() => {
|
|
||||||
if (multiple) {
|
|
||||||
if (selectedValues.length === 0) return placeholder;
|
|
||||||
if (selectedValues.length === 1) {
|
|
||||||
const opt = options.find((o) => o.value === selectedValues[0]);
|
|
||||||
return opt?.label || selectedValues[0];
|
|
||||||
}
|
|
||||||
return `${selectedValues.length} selected`;
|
|
||||||
} else {
|
|
||||||
if (!value || value === 'ALL') return allLabel;
|
|
||||||
const opt = options.find((o) => o.value === value);
|
|
||||||
return opt?.label || value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} optValue
|
|
||||||
*/
|
|
||||||
function handleSelect(optValue) {
|
|
||||||
if (multiple) {
|
|
||||||
const newValues = selectedValues.includes(optValue)
|
|
||||||
? selectedValues.filter((v) => v !== optValue)
|
|
||||||
: [...selectedValues, optValue];
|
|
||||||
value = newValues;
|
|
||||||
onchange?.(newValues);
|
|
||||||
} else {
|
|
||||||
value = optValue;
|
|
||||||
onchange?.(optValue);
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (multiple) {
|
|
||||||
value = [];
|
|
||||||
onchange?.([]);
|
|
||||||
} else {
|
|
||||||
value = 'ALL';
|
|
||||||
onchange?.('ALL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasValue = $derived(multiple ? selectedValues.length > 0 : value && value !== 'ALL');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger asChild class="">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
class={cn(
|
|
||||||
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[13px] leading-none font-medium transition-colors focus:ring-1 focus:ring-[color:var(--ring)] focus:outline-none',
|
|
||||||
hasValue
|
|
||||||
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
|
|
||||||
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span class="truncate">{displayText}</span>
|
|
||||||
{#if hasValue}
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
handleClear();
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
handleClear();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
|
|
||||||
aria-label="Clear filter"
|
|
||||||
>
|
|
||||||
<X class="size-3" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Content
|
|
||||||
align="start"
|
|
||||||
sideOffset={4}
|
|
||||||
class="w-[220px] overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-1 shadow-lg shadow-black/5"
|
|
||||||
>
|
|
||||||
<!-- All option -->
|
|
||||||
{#if !multiple}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'select-option group',
|
|
||||||
'relative flex w-full cursor-pointer items-center gap-2.5',
|
|
||||||
'rounded-lg px-2.5 py-2 text-sm',
|
|
||||||
'transition-all duration-150',
|
|
||||||
'outline-none',
|
|
||||||
!value || value === 'ALL'
|
|
||||||
? 'bg-primary/10 text-primary dark:bg-primary/20'
|
|
||||||
: 'text-foreground/80 hover:bg-muted/80 hover:text-foreground'
|
|
||||||
)}
|
|
||||||
onclick={() => handleSelect('ALL')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex h-4 w-4 items-center justify-center rounded-full',
|
|
||||||
'border transition-all duration-150',
|
|
||||||
!value || value === 'ALL'
|
|
||||||
? 'border-primary bg-primary text-primary-foreground scale-100'
|
|
||||||
: 'border-border group-hover:border-primary/50 scale-90 bg-transparent group-hover:scale-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if !value || value === 'ALL'}
|
|
||||||
<Check class="h-2.5 w-2.5" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">{allLabel}</span>
|
|
||||||
</button>
|
|
||||||
<div class="bg-border/50 my-1 h-px dark:bg-white/[0.06]"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Options -->
|
|
||||||
<div class="max-h-[240px] overflow-y-auto">
|
|
||||||
{#each options as option, i (option.value)}
|
|
||||||
{@const isSelected = multiple
|
|
||||||
? selectedValues.includes(option.value)
|
|
||||||
: value === option.value}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'select-option group',
|
|
||||||
'relative flex w-full cursor-pointer items-center gap-2.5',
|
|
||||||
'rounded-lg px-2.5 py-2 text-sm',
|
|
||||||
'transition-all duration-150',
|
|
||||||
'outline-none',
|
|
||||||
isSelected
|
|
||||||
? 'bg-primary/10 text-primary dark:bg-primary/20'
|
|
||||||
: 'text-foreground/80 hover:bg-muted/80 hover:text-foreground',
|
|
||||||
'animate-in fade-in-0 slide-in-from-left-1'
|
|
||||||
)}
|
|
||||||
style="animation-delay: {i * 20}ms"
|
|
||||||
onclick={() => handleSelect(option.value)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full',
|
|
||||||
'border transition-all duration-150',
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-primary text-primary-foreground scale-100'
|
|
||||||
: 'border-border/60 group-hover:border-primary/50 scale-90 bg-transparent group-hover:scale-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if isSelected}
|
|
||||||
<Check class="h-2.5 w-2.5" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class={cn('truncate', isSelected && 'font-medium')}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
<script>
|
|
||||||
import { ChevronDown, Check, X, Tag, Hash } from '@lucide/svelte';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {{
|
|
||||||
* id: string,
|
|
||||||
* name: string,
|
|
||||||
* color?: string,
|
|
||||||
* }} TagOption
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* tags: TagOption[],
|
|
||||||
* value?: string[],
|
|
||||||
* placeholder?: string,
|
|
||||||
* label?: string,
|
|
||||||
* class?: string,
|
|
||||||
* onchange?: (ids: string[]) => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
tags = [],
|
|
||||||
value = $bindable([]),
|
|
||||||
placeholder = 'Filter by tags...',
|
|
||||||
label: _label = '',
|
|
||||||
class: className,
|
|
||||||
onchange
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
const selectedIds = $derived(Array.isArray(value) ? value : []);
|
|
||||||
|
|
||||||
const displayText = $derived.by(() => {
|
|
||||||
if (selectedIds.length === 0) return placeholder;
|
|
||||||
if (selectedIds.length === 1) {
|
|
||||||
const tag = tags.find((t) => t.id === selectedIds[0]);
|
|
||||||
return tag?.name || selectedIds[0];
|
|
||||||
}
|
|
||||||
return `${selectedIds.length} tags`;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} tagId
|
|
||||||
*/
|
|
||||||
function toggleTag(tagId) {
|
|
||||||
const newIds = selectedIds.includes(tagId)
|
|
||||||
? selectedIds.filter((id) => id !== tagId)
|
|
||||||
: [...selectedIds, tagId];
|
|
||||||
value = newIds;
|
|
||||||
onchange?.(newIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
value = [];
|
|
||||||
onchange?.([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasSelection = $derived(selectedIds.length > 0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get vibrant color classes for tags
|
|
||||||
* @param {string | undefined} color
|
|
||||||
*/
|
|
||||||
function getTagStyle(color) {
|
|
||||||
const colorMap = {
|
|
||||||
blue: 'bg-blue-500/15 text-blue-600 border-blue-500/30 dark:bg-blue-500/25 dark:text-blue-400 dark:border-blue-400/30',
|
|
||||||
green:
|
|
||||||
'bg-emerald-500/15 text-emerald-600 border-emerald-500/30 dark:bg-emerald-500/25 dark:text-emerald-400 dark:border-emerald-400/30',
|
|
||||||
red: 'bg-rose-500/15 text-rose-600 border-rose-500/30 dark:bg-rose-500/25 dark:text-rose-400 dark:border-rose-400/30',
|
|
||||||
yellow:
|
|
||||||
'bg-amber-500/15 text-amber-600 border-amber-500/30 dark:bg-amber-500/25 dark:text-amber-400 dark:border-amber-400/30',
|
|
||||||
purple:
|
|
||||||
'bg-violet-500/15 text-violet-600 border-violet-500/30 dark:bg-violet-500/25 dark:text-violet-400 dark:border-violet-400/30',
|
|
||||||
pink: 'bg-pink-500/15 text-pink-600 border-pink-500/30 dark:bg-pink-500/25 dark:text-pink-400 dark:border-pink-400/30',
|
|
||||||
orange:
|
|
||||||
'bg-orange-500/15 text-orange-600 border-orange-500/30 dark:bg-orange-500/25 dark:text-orange-400 dark:border-orange-400/30',
|
|
||||||
cyan: 'bg-cyan-500/15 text-cyan-600 border-cyan-500/30 dark:bg-cyan-500/25 dark:text-cyan-400 dark:border-cyan-400/30',
|
|
||||||
gray: 'bg-slate-500/15 text-slate-600 border-slate-500/30 dark:bg-slate-500/25 dark:text-slate-400 dark:border-slate-400/30'
|
|
||||||
};
|
|
||||||
return colorMap[color || 'blue'] || colorMap.blue;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger asChild class="">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{...props}
|
|
||||||
class={cn(
|
|
||||||
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[13px] leading-none font-medium transition-colors focus:ring-1 focus:ring-[color:var(--ring)] focus:outline-none',
|
|
||||||
hasSelection
|
|
||||||
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
|
|
||||||
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Tag class="size-3.5 shrink-0" />
|
|
||||||
<span class="truncate">{displayText}</span>
|
|
||||||
{#if hasSelection}
|
|
||||||
<span
|
|
||||||
class="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--r-sm)] px-1.5 text-[11px] font-medium text-[color:var(--violet-soft-text)] tabular-nums opacity-90"
|
|
||||||
>
|
|
||||||
{selectedIds.length}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
clearAll();
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
clearAll();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
|
|
||||||
aria-label="Clear filter"
|
|
||||||
>
|
|
||||||
<X class="size-3" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Content
|
|
||||||
align="start"
|
|
||||||
sideOffset={4}
|
|
||||||
class="w-[280px] overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-1 shadow-lg shadow-black/5"
|
|
||||||
>
|
|
||||||
{#if tags.length === 0}
|
|
||||||
<div class="flex flex-col items-center justify-center py-6 text-center">
|
|
||||||
<Tag class="text-muted-foreground/30 mb-2 h-8 w-8" />
|
|
||||||
<p class="text-muted-foreground text-sm font-medium">No tags available</p>
|
|
||||||
<p class="text-muted-foreground/60 mt-1 text-xs">Create tags to organize your leads</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Header with selected count -->
|
|
||||||
{#if selectedIds.length > 0}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'mb-1 flex items-center justify-between rounded-lg px-2.5 py-2',
|
|
||||||
'bg-primary/5 dark:bg-primary/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span class="text-primary text-xs font-medium">
|
|
||||||
{selectedIds.length} tag{selectedIds.length > 1 ? 's' : ''} selected
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={clearAll}
|
|
||||||
class="text-primary/70 hover:text-primary text-xs font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tags list -->
|
|
||||||
<div class="max-h-[280px] space-y-0.5 overflow-y-auto">
|
|
||||||
{#each tags as tag, i (tag.id)}
|
|
||||||
{@const isSelected = selectedIds.includes(tag.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'tag-option group',
|
|
||||||
'relative flex w-full cursor-pointer items-center gap-2.5',
|
|
||||||
'rounded-lg px-2.5 py-2',
|
|
||||||
'transition-all duration-150',
|
|
||||||
'outline-none',
|
|
||||||
isSelected ? 'bg-primary/10 dark:bg-primary/15' : 'hover:bg-muted/80',
|
|
||||||
'animate-in fade-in-0 slide-in-from-left-1'
|
|
||||||
)}
|
|
||||||
style="animation-delay: {i * 15}ms"
|
|
||||||
onclick={() => toggleTag(tag.id)}
|
|
||||||
>
|
|
||||||
<!-- Checkbox -->
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded',
|
|
||||||
'border transition-all duration-150',
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-primary text-primary-foreground scale-100'
|
|
||||||
: 'border-border/60 group-hover:border-primary/50 scale-90 bg-transparent group-hover:scale-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if isSelected}
|
|
||||||
<Check class="h-2.5 w-2.5" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tag badge -->
|
|
||||||
<span
|
|
||||||
class={cn(
|
|
||||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5',
|
|
||||||
'text-xs font-semibold',
|
|
||||||
'transition-all duration-150',
|
|
||||||
getTagStyle(tag.color),
|
|
||||||
isSelected && 'ring-primary/20 ring-1'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Hash class="h-3 w-3 opacity-60" />
|
|
||||||
{tag.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Selected indicator glow -->
|
|
||||||
{#if isSelected}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'bg-primary absolute right-2 h-2 w-2 rounded-full',
|
|
||||||
'animate-in fade-in zoom-in duration-200'
|
|
||||||
)}
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export { default as SearchInput } from './SearchInput.svelte';
|
|
||||||
export { default as SelectFilter } from './SelectFilter.svelte';
|
|
||||||
export { default as DateRangeFilter } from './DateRangeFilter.svelte';
|
|
||||||
export { default as FilterBar } from './FilterBar.svelte';
|
|
||||||
export { default as TagFilter } from './TagFilter.svelte';
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<script>
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Props
|
|
||||||
* @property {'GET' | 'POST' | 'get' | 'post' | 'dialog' | 'DIALOG'} [method]
|
|
||||||
* @property {string} [action]
|
|
||||||
* @property {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'} [enctype]
|
|
||||||
* @property {(this: HTMLFormElement, event: SubmitEvent) => void} [onsubmit]
|
|
||||||
* @property {import('$app/forms').SubmitFunction} [useEnhance]
|
|
||||||
* @property {string} [class]
|
|
||||||
* @property {string} [errorMessage]
|
|
||||||
* @property {import('svelte').Snippet} children
|
|
||||||
* @property {import('svelte').Snippet} actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {Props} */
|
|
||||||
let {
|
|
||||||
method = 'POST',
|
|
||||||
action = '',
|
|
||||||
enctype,
|
|
||||||
onsubmit,
|
|
||||||
useEnhance = undefined,
|
|
||||||
class: className,
|
|
||||||
errorMessage = '',
|
|
||||||
children,
|
|
||||||
actions
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet body()}
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-[color:var(--border-faint)] bg-[color:var(--bg)]/95 backdrop-blur-[1px]"
|
|
||||||
>
|
|
||||||
<div class="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-7 py-3 md:px-8">
|
|
||||||
{#if errorMessage}
|
|
||||||
<p class="truncate text-[12px] text-[color:var(--red)]">{errorMessage}</p>
|
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{@render actions()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if useEnhance}
|
|
||||||
<form
|
|
||||||
{method}
|
|
||||||
{action}
|
|
||||||
{enctype}
|
|
||||||
{onsubmit}
|
|
||||||
use:enhance={useEnhance}
|
|
||||||
class={cn(
|
|
||||||
'mx-auto flex w-full max-w-[680px] flex-col',
|
|
||||||
'px-7 pt-4 pb-[88px] md:px-8',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render body()}
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<form
|
|
||||||
{method}
|
|
||||||
{action}
|
|
||||||
{enctype}
|
|
||||||
{onsubmit}
|
|
||||||
class={cn(
|
|
||||||
'mx-auto flex w-full max-w-[680px] flex-col',
|
|
||||||
'px-7 pt-4 pb-[88px] md:px-8',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{@render body()}
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as FormShell } from './FormShell.svelte';
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import Root from './input.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Input
|
|
||||||
};
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: HTMLInputElement | null,
|
|
||||||
* value?: string | number,
|
|
||||||
* type?: string,
|
|
||||||
* files?: FileList | null,
|
|
||||||
* class?: string,
|
|
||||||
* 'data-slot'?: string,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
value = $bindable(),
|
|
||||||
type = 'text',
|
|
||||||
files = $bindable(),
|
|
||||||
class: className,
|
|
||||||
'data-slot': dataSlot = 'input',
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const baseClass = [
|
|
||||||
'flex h-9 w-full min-w-0 px-3',
|
|
||||||
'rounded-[var(--r-md)] border border-[color:var(--border)]',
|
|
||||||
'bg-[color:var(--bg-input)] text-[13px] text-[color:var(--text)]',
|
|
||||||
'leading-none outline-none',
|
|
||||||
'placeholder:text-[color:var(--text-subtle)]',
|
|
||||||
'transition-[border-color,box-shadow] duration-150',
|
|
||||||
'hover:border-[color:var(--border-strong)]',
|
|
||||||
'focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
|
|
||||||
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]',
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
'selection:bg-[color:var(--text)] selection:text-[color:var(--bg)]'
|
|
||||||
].join(' ');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if type === 'file'}
|
|
||||||
<input
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot={dataSlot}
|
|
||||||
class={cn(baseClass, 'pt-1.5 text-[13px] font-medium', className)}
|
|
||||||
type="file"
|
|
||||||
bind:files
|
|
||||||
bind:value
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot={dataSlot}
|
|
||||||
class={cn(baseClass, className)}
|
|
||||||
{type}
|
|
||||||
bind:value
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import Root from './label.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Label
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Label as LabelPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), class: className = undefined, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
bind:ref
|
|
||||||
data-slot="label"
|
|
||||||
class={cn(
|
|
||||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
<script>
|
|
||||||
import { ChevronLeft, ChevronRight, ChevronsUpDown } from '@lucide/svelte';
|
|
||||||
import * as Popover from '#lib/components/ui/popover/index.js';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* page?: number,
|
|
||||||
* limit?: number,
|
|
||||||
* total?: number,
|
|
||||||
* limitOptions?: number[],
|
|
||||||
* class?: string,
|
|
||||||
* onPageChange?: (page: number) => void,
|
|
||||||
* onLimitChange?: (limit: number) => void,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
page = 1,
|
|
||||||
limit = 10,
|
|
||||||
total = 0,
|
|
||||||
limitOptions = [10, 25, 50, 100],
|
|
||||||
class: className,
|
|
||||||
onPageChange,
|
|
||||||
onLimitChange
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(total / limit) || 1);
|
|
||||||
const startItem = $derived((page - 1) * limit + 1);
|
|
||||||
const endItem = $derived(Math.min(page * limit, total));
|
|
||||||
|
|
||||||
let limitPopoverOpen = $state(false);
|
|
||||||
|
|
||||||
function handlePrevious() {
|
|
||||||
if (page > 1) {
|
|
||||||
onPageChange?.(page - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNext() {
|
|
||||||
if (page < totalPages) {
|
|
||||||
onPageChange?.(page + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} newLimit
|
|
||||||
*/
|
|
||||||
function handleLimitSelect(newLimit) {
|
|
||||||
limitPopoverOpen = false;
|
|
||||||
if (newLimit !== limit) {
|
|
||||||
onLimitChange?.(newLimit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if total > 0}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'relative mt-6 flex flex-col items-center gap-4 px-1 sm:flex-row sm:justify-between',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Left side: Results summary with refined typography -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Showing
|
|
||||||
<span
|
|
||||||
class="bg-muted/50 text-foreground mx-1 inline-flex items-center justify-center rounded-md px-2 py-0.5 font-semibold tabular-nums"
|
|
||||||
>
|
|
||||||
{startItem}-{endItem}
|
|
||||||
</span>
|
|
||||||
of
|
|
||||||
<span class="text-foreground ml-1 font-semibold">{total}</span>
|
|
||||||
results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right side: Controls -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Rows per page selector -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-muted-foreground/70 text-xs font-medium tracking-wide uppercase"
|
|
||||||
>Rows</span
|
|
||||||
>
|
|
||||||
<Popover.Root bind:open={limitPopoverOpen}>
|
|
||||||
<Popover.Trigger asChild class="">
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group border-border/60 bg-card/50 text-foreground hover:border-border hover:bg-card focus-visible:ring-ring/50 inline-flex h-9 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium shadow-sm backdrop-blur-sm transition-all duration-150 focus-visible:ring-2 focus-visible:outline-none"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span class="tabular-nums">{limit}</span>
|
|
||||||
<ChevronsUpDown
|
|
||||||
class="text-muted-foreground group-hover:text-foreground h-3.5 w-3.5 transition-colors"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content align="end" class="w-28 p-1.5">
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
{#each limitOptions as option (option)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={cn(
|
|
||||||
'flex w-full items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
||||||
option === limit
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'text-foreground hover:bg-muted'
|
|
||||||
)}
|
|
||||||
onclick={() => handleLimitSelect(option)}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="bg-border/60 h-5 w-px"></div>
|
|
||||||
|
|
||||||
<!-- Page navigation -->
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onclick={handlePrevious}
|
|
||||||
aria-label="Previous page"
|
|
||||||
class="group border-border/60 bg-card/50 text-muted-foreground hover:border-border hover:bg-card hover:text-foreground focus-visible:ring-ring/50 inline-flex h-9 w-9 items-center justify-center rounded-lg border shadow-sm backdrop-blur-sm transition-all duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Page indicator pill -->
|
|
||||||
<div
|
|
||||||
class="border-border/40 bg-muted/30 flex min-w-[7rem] items-center justify-center gap-1 rounded-lg border px-3 py-1.5"
|
|
||||||
>
|
|
||||||
<span class="text-foreground text-sm font-semibold tabular-nums">{page}</span>
|
|
||||||
<span class="text-muted-foreground text-xs">/</span>
|
|
||||||
<span class="text-muted-foreground text-sm tabular-nums">{totalPages}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onclick={handleNext}
|
|
||||||
aria-label="Next page"
|
|
||||||
class="group border-border/60 bg-card/50 text-muted-foreground hover:border-border hover:bg-card hover:text-foreground focus-visible:ring-ring/50 inline-flex h-9 w-9 items-center justify-center rounded-lg border shadow-sm backdrop-blur-sm transition-all duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<ChevronRight class="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as Pagination } from './Pagination.svelte';
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
|
||||||
import Content from './popover-content.svelte';
|
|
||||||
import Trigger from './popover-trigger.svelte';
|
|
||||||
const Root = PopoverPrimitive.Root;
|
|
||||||
const Close = PopoverPrimitive.Close;
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Content,
|
|
||||||
Trigger,
|
|
||||||
Close,
|
|
||||||
//
|
|
||||||
Root as Popover,
|
|
||||||
Content as PopoverContent,
|
|
||||||
Trigger as PopoverTrigger,
|
|
||||||
Close as PopoverClose
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {{
|
|
||||||
* ref?: any,
|
|
||||||
* class?: string,
|
|
||||||
* sideOffset?: number,
|
|
||||||
* align?: "start" | "center" | "end",
|
|
||||||
* portalProps?: any,
|
|
||||||
* [key: string]: any
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
sideOffset = 4,
|
|
||||||
align = /** @type {"start" | "center" | "end"} */ ('center'),
|
|
||||||
portalProps,
|
|
||||||
...restProps
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PopoverPrimitive.Portal {...portalProps}>
|
|
||||||
<PopoverPrimitive.Content
|
|
||||||
bind:ref
|
|
||||||
data-slot="popover-content"
|
|
||||||
{sideOffset}
|
|
||||||
{align}
|
|
||||||
class={cn(
|
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<script>
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PopoverPrimitive.Trigger
|
|
||||||
bind:ref
|
|
||||||
data-slot="popover-trigger"
|
|
||||||
class={cn('', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import Root from './progress.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Progress
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Progress as ProgressPrimitive } from 'bits-ui';
|
|
||||||
import { cn } from '#lib/utils.js';
|
|
||||||
|
|
||||||
let { ref = $bindable(null), class: className, max = 100, value, ...restProps } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ProgressPrimitive.Root
|
|
||||||
bind:ref
|
|
||||||
data-slot="progress"
|
|
||||||
class={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
|
||||||
{value}
|
|
||||||
{max}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-slot="progress-indicator"
|
|
||||||
class="bg-primary h-full w-full flex-1 transition-all"
|
|
||||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
|
||||||
></div>
|
|
||||||
</ProgressPrimitive.Root>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue