This commit is contained in:
Ashwin 2026-10-21 09:25:18 +05:30
parent 8e45236d5a
commit 601ca1da48
166 changed files with 27 additions and 8743 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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 = [

View file

@ -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))

View file

@ -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.

View file

@ -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

View file

@ -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"""

View file

@ -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())

View file

@ -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
# =============================================================================

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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`

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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",

View file

@ -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)

View file

@ -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
};
}

View file

@ -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)]">&middot;</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>

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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>

View file

@ -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>

View file

@ -1,2 +0,0 @@
export { default as LinkedinIcon } from './LinkedinIcon.svelte';
export { default as GithubIcon } from './GithubIcon.svelte';

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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} />

View file

@ -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
};

View file

@ -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}
/>

View file

@ -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];
}

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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';

View file

@ -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>

View file

@ -1,2 +0,0 @@
export { default as Badge } from './badge.svelte';
export { badgeVariants } from './badge.svelte';

View file

@ -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}

View file

@ -1 +0,0 @@
export { default as BulkActionBar } from './BulkActionBar.svelte';

View file

@ -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>

View file

@ -1,3 +0,0 @@
import Calendar from './Calendar.svelte';
export { Calendar };

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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
};

View file

@ -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>

View file

@ -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>

View file

@ -1,2 +0,0 @@
export { default as CrmDrawer } from './CrmDrawer.svelte';
export { default as CrmPropertyRow } from './CrmPropertyRow.svelte';

View file

@ -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()}

View file

@ -1 +0,0 @@
export { default as CrmTable } from './CrmTable.svelte';

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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} />

View file

@ -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
};

View file

@ -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>

View file

@ -1 +0,0 @@
export { default as EditableMultiSelect } from './EditableMultiSelect.svelte';

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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}

View file

@ -1 +0,0 @@
export { default as FormShell } from './FormShell.svelte';

View file

@ -1,7 +0,0 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input
};

View file

@ -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}

View file

@ -1,7 +0,0 @@
import Root from './label.svelte';
export {
Root,
//
Root as Label
};

View file

@ -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}
/>

View file

@ -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}

View file

@ -1 +0,0 @@
export { default as Pagination } from './Pagination.svelte';

View file

@ -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
};

View file

@ -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>

View file

@ -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}
/>

View file

@ -1,7 +0,0 @@
import Root from './progress.svelte';
export {
Root,
//
Root as Progress
};

View file

@ -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