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
|
||||
run: uv sync --frozen
|
||||
|
||||
# The suite runs with --no-migrations for speed, so nothing else would
|
||||
# catch a model change that is missing its migration.
|
||||
# Running the migrations cannot notice a model change that has no
|
||||
# migration yet, so this checks for one explicitly.
|
||||
- name: Check for missing migrations
|
||||
working-directory: backend
|
||||
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`.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -507,8 +507,7 @@ class TestPublicCsatResolvesOrgFirst:
|
|||
def test_public_survey_answers_under_enforced_rls(client):
|
||||
"""The anonymous survey GET and POST work under a role that enforces RLS.
|
||||
|
||||
Runs only against PostgreSQL as a non-superuser (``RLS_ENFORCE=1
|
||||
--ds=crm.test_settings_postgres``): a superuser bypasses RLS, and the first
|
||||
Runs only against PostgreSQL as a non-superuser (``--ds=crm.test_settings_postgres``): a superuser bypasses RLS, and the first
|
||||
assertion below says so rather than passing vacuously.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Base model for all organization-scoped entities.
|
||||
|
|
@ -153,7 +126,6 @@ class BaseOrgModel(BaseModel):
|
|||
This ensures:
|
||||
- Required org field (no null values)
|
||||
- Per-org indexes for performance
|
||||
- OrgScopedManager for easier filtering
|
||||
|
||||
RLS Protection:
|
||||
---------------
|
||||
|
|
@ -174,8 +146,6 @@ class BaseOrgModel(BaseModel):
|
|||
help_text="Organization this record belongs to",
|
||||
)
|
||||
|
||||
objects = OrgScopedManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
indexes = [
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class GetProfileAndOrg:
|
|||
# another's without any view knowing about it.
|
||||
#
|
||||
# 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
|
||||
# after activating would hand the next tenant its own day boundary.
|
||||
activate_org_timezone(getattr(request, "org", None))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Usage in settings.py:
|
|||
MIDDLEWARE = [
|
||||
...
|
||||
'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__)
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Stricter middleware that fails if org context is not set.
|
||||
|
||||
Use this instead of SetOrgContext when you want to ensure
|
||||
all requests have proper org context (after RLS is fully enabled).
|
||||
Sets `app.current_org` for the request and refuses a request with no org
|
||||
context, except on the exempt paths below.
|
||||
|
||||
Usage in settings.py:
|
||||
MIDDLEWARE = [
|
||||
|
|
@ -263,7 +195,7 @@ class RequireOrgContext:
|
|||
# Set org context
|
||||
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
|
||||
# pooled or otherwise reused connection, skipping it leaks this
|
||||
# tenant's context to whoever borrows the connection next.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ Covers:
|
|||
- SessionToken.revoke() and cleanup_expired()
|
||||
- Activity.created_on_arrow property
|
||||
- AssignableMixin properties (get_team_users, etc.)
|
||||
- OrgScopedQuerySet and OrgScopedManager methods
|
||||
|
||||
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_and_assigned_users.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)
|
||||
|
||||
|
||||
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):
|
||||
"""Test that models properly handle org field"""
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ a real PostgreSQL with a non-superuser role.
|
|||
|
||||
import pytest
|
||||
|
||||
from common.middleware.rls_context import RequireOrgContext, SetOrgContext
|
||||
from common.middleware.rls_context import RequireOrgContext
|
||||
|
||||
|
||||
class _Boom(Exception):
|
||||
|
|
@ -44,16 +44,14 @@ def _instrument(middleware_cls, get_response):
|
|||
return mw, calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("middleware_cls", [SetOrgContext, RequireOrgContext])
|
||||
def test_reset_runs_on_the_happy_path(middleware_cls):
|
||||
mw, calls = _instrument(middleware_cls, lambda request: "response")
|
||||
def test_reset_runs_on_the_happy_path():
|
||||
mw, calls = _instrument(RequireOrgContext, lambda request: "response")
|
||||
|
||||
assert mw(_FakeRequest()) == "response"
|
||||
assert calls == ["set", "reset"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("middleware_cls", [SetOrgContext, RequireOrgContext])
|
||||
def test_reset_runs_when_the_view_raises(middleware_cls):
|
||||
def test_reset_runs_when_the_view_raises():
|
||||
"""The regression this file exists for.
|
||||
|
||||
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):
|
||||
raise _Boom("view exploded")
|
||||
|
||||
mw, calls = _instrument(middleware_cls, boom)
|
||||
mw, calls = _instrument(RequireOrgContext, boom)
|
||||
|
||||
with pytest.raises(_Boom):
|
||||
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(middleware_cls):
|
||||
def test_the_exception_still_propagates():
|
||||
"""The finally must not swallow the error into a silent 200."""
|
||||
|
||||
def boom(request):
|
||||
raise _Boom("view exploded")
|
||||
|
||||
mw, _ = _instrument(middleware_cls, boom)
|
||||
mw, _ = _instrument(RequireOrgContext, boom)
|
||||
|
||||
with pytest.raises(_Boom, match="view exploded"):
|
||||
mw(_FakeRequest())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import pytz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""RLS-enforced proof for the portal token → org resolution (Option 1).
|
||||
|
||||
These run only under a non-superuser Postgres role (``RLS_ENFORCE=1
|
||||
--ds=crm.test_settings_postgres``), because the whole point is the behaviour
|
||||
These run only under a non-superuser Postgres role (``--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
|
||||
policy makes the estimate row invisible, so the anonymous portal view can only
|
||||
work if it resolves the org from the unscoped lookup first.
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ dependencies = [
|
|||
"django-crum>=0.7.9",
|
||||
"disposable-email-domains>=0.0.280",
|
||||
"redis>=8.1.0",
|
||||
"pytz>=2026.5",
|
||||
"requests>=2.34.2",
|
||||
"faker>=40.43.0",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
|
|
|
|||
|
|
@ -11,5 +11,4 @@ addopts =
|
|||
--cov-report=html:htmlcov
|
||||
--cov-report=xml:coverage.xml
|
||||
markers =
|
||||
slow: marks tests as slow
|
||||
postgres_only: marks tests that require PostgreSQL
|
||||
|
|
|
|||
|
|
@ -728,7 +728,6 @@ dependencies = [
|
|||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pytz" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
{ name = "sentry-sdk" },
|
||||
|
|
@ -772,7 +771,6 @@ requires-dist = [
|
|||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.6" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.4" },
|
||||
{ name = "pytz", specifier = ">=2026.5" },
|
||||
{ name = "redis", specifier = ">=8.1.0" },
|
||||
{ name = "requests", specifier = ">=2.34.2" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyyaml"
|
||||
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` (`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
|
||||
`Model.objects.for_request(request)` helpers), and a `["org", "-created_at"]` index. Its own
|
||||
to `common.Org` and a `["org", "-created_at"]` index. Its own
|
||||
docstring states the intent plainly: "All tenant-owned models should inherit from this instead of
|
||||
`BaseModel`."
|
||||
|
||||
|
|
@ -58,9 +57,7 @@ class CaseWatcher(BaseModel):
|
|||
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`
|
||||
field to a new model. Doing so also gets you the `OrgScopedManager` helpers
|
||||
(`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
|
||||
field to a new model, 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
(`DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK`: `PATAuthentication`, then simplejwt's
|
||||
`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
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`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
|
||||
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
|
||||
|
||||
`pytest.ini` declares two custom markers:
|
||||
`pytest.ini` declares one custom marker:
|
||||
|
||||
```ini
|
||||
markers =
|
||||
slow: marks tests as slow
|
||||
postgres_only: marks tests that require PostgreSQL
|
||||
```
|
||||
|
||||
`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
|
||||
`uv run pytest -m postgres_only --collect-only -q`. `slow` is declared and currently applied to
|
||||
nothing.
|
||||
`uv run pytest -m postgres_only --collect-only -q`.
|
||||
|
||||
## 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
|
||||
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
|
||||
user in that org before it's trusted. (The file also defines a second, older middleware class,
|
||||
`SetOrgContext`, referenced in that file's own module docstring, but `settings.py` wires
|
||||
`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), '')`,
|
||||
user in that org before it's trusted. A request that arrives with no org context is refused with a
|
||||
403 rather than run without one. 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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@internationalized/date": "^3.12.4",
|
||||
"@lucide/svelte": "^1.57.0",
|
||||
"@sveltejs/adapter-node": "^6.0.0",
|
||||
"@sveltejs/kit": "^3.0.1",
|
||||
|
|
|
|||
|
|
@ -43,9 +43,6 @@ importers:
|
|||
'@eslint/js':
|
||||
specifier: ^10.0.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':
|
||||
specifier: ^1.57.0
|
||||
version: 1.57.0(svelte@5.57.2)
|
||||
|
|
|
|||
|
|
@ -160,111 +160,3 @@ export async function apiRequest(endpoint, options = {}, locals) {
|
|||
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